1. 概述
在上一篇文章《Spring Cloud – 啓動》中,我們已經構建了一個基本的 Spring Cloud應用程序。 本文將展示如何對其進行安全保護。
我們將自然地使用 Spring Security通過 Spring Session共享會話以及 Redis。 這種方法易於設置和擴展到許多業務場景。 如果您不熟悉 Spring Session,請查看這篇文章。
共享會話使我們能夠登錄我們的網關服務,並將該身份驗證傳播到我們系統中的任何其他服務。
如果您不熟悉 Redis 或 Spring Security,那麼在此時快速回顧這些主題是一個好主意。 雖然大部分文章可以直接複製粘貼到應用程序中,但瞭解“底層”發生了什麼沒有替代。
有關 Redis的介紹,請閲讀此教程。有關 Spring Security的介紹,請閲讀 spring-security-login、role-and-privilege-for-spring-security-registration 和 spring-security-session。要獲得對 Spring Security的完整理解,請查看 learn-spring-security-the-master-class。
2. Maven 設置
首先,我們將 spring-boot-starter-security 依賴添加到系統中每個模塊中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
由於我們使用 Spring 依賴管理,因此可以省略 spring-boot-starter 依賴的版本。
接下來,我們修改每個應用程序的 pom.xml 文件,添加 spring-session 和 spring-boot-starter-data-redis 依賴:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
只有四個我們的應用程序會與 Spring Session 集成:discovery、gateway、book-service 和 rating-service
接下來,在所有三個服務中的同一目錄中添加一個會話配置類:
@EnableRedisHttpSession
public class SessionConfig
extends AbstractHttpSessionApplicationInitializer {
}
請注意,對於 gateway 服務,我們需要使用不同的註解,即 @EnableRedisWebSession。
最後,添加這些屬性到我們 git 倉庫中的三個 *.properties 文件中:
spring.redis.host=localhost
spring.redis.port=6379
現在,讓我們進入服務特定的配置。
3. 加密配置服務
配置服務包含敏感信息,通常與數據庫連接和API密鑰相關。我們不能泄露這些信息,所以讓我們立即着手並確保該服務的安全性。讓我們將安全屬性添加到 application.properties 文件中,該文件位於配置服務的 src/main/resources 目錄中:
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
security.user.name=configUser
security.user.name=configUser
security.user.password=configPassword
security.user.password=configPassword
security.user.role=SYSTEM
security.user.role=SYSTEM
這將配置我們的服務使用發現進行登錄。此外,我們還通過 application.properties 文件配置了我們的安全設置。
現在,讓我們配置我們的發現服務。
4. Securing Discovery Service
我們的發現服務存儲了關於應用程序中所有服務位置的敏感信息。它還註冊了這些服務的全新實例。
如果惡意客户端獲得訪問權限,他們將瞭解我們系統中的所有服務的網絡位置,並且能夠將自己的惡意服務註冊到我們的應用程序中。確保發現服務得到安全保障至關重要。
4.1. Security Configuration
讓我們添加一個安全過濾器來保護其他服務將使用的端點:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("discUser")
.password("{noop}discPassword").roles("SYSTEM");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(HttpMethod.GET, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.POST, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.PUT, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.DELETE, "/eureka/**")
.hasRole("SYSTEM")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
這將設置我們的服務具有“SYSTEM”用户。這是一個基本的Spring Security配置,具有一些特殊之處。讓我們來了解這些特殊之處:
- .sessionCreationPolicy – 告訴
Spring在用户登錄時始終創建一個會話
- .requestMatchers – 限制此過濾器應用於哪些端點
此安全過濾器配置了一個與發現服務相關的隔離身份驗證環境。
4.2. Securing Eureka Dashboard
由於我們的發現應用程序具有用於查看當前註冊服務的便捷 UI,讓我們使用第二個安全過濾器並將其與我們應用程序中的其餘部分的身份驗證鏈接起來。請注意,沒有@Order()標籤意味着此過濾器將作為最後評估的過濾器進行評估:
@Configuration
public static class AdminSecurityConfig {
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.httpBasic(basic -> basic.disable())
.authorizeRequests()
.requestMatchers(HttpMethod.GET, "/").hasRole("ADMIN")
.requestMatchers("/info", "/health").authenticated()
.anyRequest().denyAll()
.and().csrf(csrf -> csrf.disable());
}
}
將此配置類放在SecurityConfig類中。這將創建一個第二個安全過濾器,用於控制對我們 UI 的訪問。此過濾器具有一些不尋常的特性,讓我們來了解這些特性:
- httpBasic().disable() – 告訴
Spring為此過濾器禁用所有身份驗證程序
- sessionCreationPolicy – 我們將其設置為
NEVER,以指示我們需要在用户訪問受此過濾器保護的資源之前,才需要用户具有會話
此過濾器將不會設置用户會話,並且依賴於Redis來填充共享的安全上下文。因此,它取決於另一個服務,即網關,為提供身份驗證提供服務。
4.3. Authenticating With Config Service
在發現項目中,讓我們向bootstrap.properties中的 src/main/resources 中添加兩個屬性:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
這些屬性將允許發現服務在啓動時與配置服務進行身份驗證。
讓我們更新我們的discovery.properties在我們的 Git 倉庫中
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
我們已將基本的身份驗證憑據添加到我們的discovery服務,以便它可以與config服務進行通信。此外,我們配置Eureka為獨立模式運行,通過告訴我們的服務不要與自己註冊來實現。
讓我們提交該文件到git倉庫。否則,更改將不會被檢測到。
5. Securing Gateway Service
Our gateway service is the only piece of our application we want to expose to the world. As such it will need security to ensure that only authenticated users can access sensitive information.
5.1. Security Configuration
Let’s create a SecurityConfig class like our discovery service and overwrite the methods with this content:
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build();
UserDetails adminUser = User.withUsername("admin")
.password(passwordEncoder().encode("admin"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, adminUser);
}
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(
new RedirectServerAuthenticationSuccessHandler("/home/index.html"))
.and().authorizeExchange()
.pathMatchers("/book-service/**", "/rating-service/**", "/login*", "/")
.permitAll()
.pathMatchers("/eureka/**").hasRole("ADMIN")
.anyExchange().authenticated().and()
.logout().and().csrf().disable().httpBasic(withDefaults());
return http.build();
}
}
This configuration is pretty straightforward. We declare a security filter with form login that secures a variety of endpoints.
The security on /eureka/** is to protect some static resources we will serve from our gateway service for the Eureka status page. If you are building the project with the article, copy the resource/static folder from the gateway project on Github to your project.
Now we have to add @EnableRedisWebSession:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}
The Spring Cloud Gateway filter automatically will grab the request as it is redirected after login and add the session key as a cookie in the header. This will propagate authentication to any backing service after login.
5.2. Authenticating With Config and Discovery Service
Let us add the following authentication properties to the bootstrap.properties file in src/main/resources of the gateway service:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
Next, let’s update our gateway.properties in our Git repository
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379
We have added session management to always generate sessions because we only have one security filter we can set that in the properties file. Next, we add our Redis host and server properties.
We can remove the serviceUrl.defaultZone property from the gateway.properties file in our configuration git repository. This value is duplicated in the bootstrap file.
Let’s commit the file to the Git repository, otherwise, the changes will not be detected.
6. 安全書籍服務
書籍服務服務器將存儲由各種用户控制的敏感信息。為了防止我們的系統中的受保護信息泄露,必須對該服務進行安全保護。
6.1. 安全配置
為了安全我們的書籍服務,我們將從網關複製 SecurityConfig 類,並覆蓋以下內容:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Autowired
public void registerAuthProvider(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests((auth) ->
auth.requestMatchers(HttpMethod.GET, "/books")
.permitAll()
.requestMatchers(HttpMethod.GET, "/books/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/books")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/books/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/books/*")
.hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.build();
}
}
6.2. 屬性
將這些屬性添加到 bootstrap.properties 文件中,該文件位於書籍服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們將這些屬性添加到我們的 book-service.properties 文件中,該文件位於我們的 Git 存儲庫中:
management.security.sessions=never
我們可以從 book-service.properties 文件中刪除 serviceUrl.defaultZone 屬性,該屬性位於我們的配置 Git 存儲庫中。此值在 bootstrap 文件中重複。
請提交這些更改,以便書籍服務可以拾取它們。
7. 保護評分服務
評分服務也需要進行保護。
7.1. 安全配置
為了保護我們的評分服務,我們將從網關複製 SecurityConfig 類並覆蓋方法內容如下:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService users() {
return new InMemoryUserDetailsManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.authorizeHttpRequests((auth) ->
auth.requestMatchers("^/ratings\\?bookId.*$")
.authenticated()
.requestMatchers(HttpMethod.POST, "/ratings")
.authenticated()
.requestMatchers(HttpMethod.PATCH, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/ratings")
.hasRole("ADMIN")
.anyRequest()
.authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
我們可以刪除 configureGlobal() 方法來自 gateway 服務。
7.2. 屬性
將以下屬性添加到 bootstrap.properties 文件中,位於評分服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們為評分服務添加屬性到 rating-service.properties 文件中,位於我們的 git 倉庫中:
management.security.sessions=never
我們可以刪除 serviceUrl.defaultZone 屬性來自 rating-service.properties 文件中,位於我們的配置 git 倉庫中。 此值在 bootstrap 文件中重複。
請提交這些更改,以便評分服務可以拾取它們。
8. Running and Testing
Start Redis and all the services for the application: config, discovery, gateway, book-service, rating-service . Now let’s test!
First, let’s create a test class in our gateway project and create a method for our test:
public class GatewayApplicationLiveTest {
@Test
public void testAccess() {
...
}
}
Next, let’s set up our test and validate that we can access our unprotected book-service/books resource by adding this code snippet inside our test method:
TestRestTemplate testRestTemplate = new TestRestTemplate();
String testUrl = "http://localhost:8080";
ResponseEntity<String> response = testRestTemplate
.getForEntity(testUrl + "/book-service/books", String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run this test and verify the results. If we see failures confirm that the entire application started successfully and that configurations were loaded from our configuration git repository.
Now let’s test that our users will be redirected to log in when visiting a protected resource as an unauthenticated user by appending this code to the end of the test method:
response = testRestTemplate
.getForEntity(testUrl + "/home/index.html", String.class);
Assert.assertEquals(HttpStatus.FOUND, response.getStatusCode());
Assert.assertEquals("http://localhost:8080/login", response.getHeaders()
.get("Location").get(0));
Run the test again and confirm that it succeeds.
Next, let’s actually log in and then use our session to access the user protected result:
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
now, let us extract the session from the cookie and propagate it to the following request:
String sessionCookie = response.getHeaders().get("Set-Cookie")
.get(0).split(";")[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
and request the protected resource:
response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run the test again to confirm the results.
Now, let’s try to access the admin section with the same session:
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
Run the test again, and as expected we are restricted from accessing admin areas as a plain old user.
The next test will validate that we can log in as the admin and access the admin protected resource:
form.clear();
form.add("username", "admin");
form.add("password", "admin");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
sessionCookie = response.getHeaders().get("Set-Cookie").get(0).split(";")[0];
headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
httpEntity = new HttpEntity<>(headers);
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Our test is getting big! But we can see when we run it that by logging in as the admin we gain access to the admin resource.
Our final test is accessing our discovery server through our gateway. To do this add this code to the end of our test:
response = testRestTemplate.exchange(testUrl + "/discovery",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Run this test one last time to confirm that everything is working. Success!!!
Did you miss that? Because we logged in on our gateway service and viewed content on our book, rating, and discovery services without having to log in on four separate servers!
By utilizing Spring Session to propagate our authentication object between servers we are able to log in once on the gateway and use that authentication to access controllers on any number of backing services.
9. 結論
雲安全無疑變得更加複雜。但藉助 Spring Security 和Spring Session,我們可以輕鬆解決這一關鍵問題。
我們現在擁有一個帶有云安全保護的應用程序。使用 Spring Cloud Gateway 和Spring Session ,我們可以在僅一個服務中對用户進行身份驗證,並將該身份驗證傳播到整個應用程序。這意味着我們可以輕鬆地將我們的應用程序分解為適當的領域,並對每個領域進行適當的保護。
我們的發現服務存儲了關於應用程序中所有服務位置的敏感信息。它還註冊了這些服務的全新實例。
如果惡意客户端獲得訪問權限,他們將瞭解我們系統中的所有服務的網絡位置,並且能夠將自己的惡意服務註冊到我們的應用程序中。確保發現服務得到安全保障至關重要。
4.1. Security Configuration
讓我們添加一個安全過濾器來保護其他服務將使用的端點:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("discUser")
.password("{noop}discPassword").roles("SYSTEM");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(HttpMethod.GET, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.POST, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.PUT, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.DELETE, "/eureka/**")
.hasRole("SYSTEM")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
這將設置我們的服務具有“SYSTEM”用户。這是一個基本的Spring Security配置,具有一些特殊之處。讓我們來了解這些特殊之處:
- .sessionCreationPolicy – 告訴
Spring在用户登錄時始終創建一個會話
- .requestMatchers – 限制此過濾器應用於哪些端點
此安全過濾器配置了一個與發現服務相關的隔離身份驗證環境。
4.2. Securing Eureka Dashboard
由於我們的發現應用程序具有用於查看當前註冊服務的便捷 UI,讓我們使用第二個安全過濾器並將其與我們應用程序中的其餘部分的身份驗證鏈接起來。請注意,沒有@Order()標籤意味着此過濾器將作為最後評估的過濾器進行評估:
@Configuration
public static class AdminSecurityConfig {
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.httpBasic(basic -> basic.disable())
.authorizeRequests()
.requestMatchers(HttpMethod.GET, "/").hasRole("ADMIN")
.requestMatchers("/info", "/health").authenticated()
.anyRequest().denyAll()
.and().csrf(csrf -> csrf.disable());
}
}
將此配置類放在SecurityConfig類中。這將創建一個第二個安全過濾器,用於控制對我們 UI 的訪問。此過濾器具有一些不尋常的特性,讓我們來了解這些特性:
- httpBasic().disable() – 告訴
Spring為此過濾器禁用所有身份驗證程序
- sessionCreationPolicy – 我們將其設置為
NEVER,以指示我們需要在用户訪問受此過濾器保護的資源之前,才需要用户具有會話
此過濾器將不會設置用户會話,並且依賴於Redis來填充共享的安全上下文。因此,它取決於另一個服務,即網關,為提供身份驗證提供服務。
4.3. Authenticating With Config Service
在發現項目中,讓我們向bootstrap.properties中的 src/main/resources 中添加兩個屬性:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
這些屬性將允許發現服務在啓動時與配置服務進行身份驗證。
讓我們更新我們的discovery.properties在我們的 Git 倉庫中
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
我們已將基本的身份驗證憑據添加到我們的discovery服務,以便它可以與config服務進行通信。此外,我們配置Eureka為獨立模式運行,通過告訴我們的服務不要與自己註冊來實現。
讓我們提交該文件到git倉庫。否則,更改將不會被檢測到。
5. Securing Gateway Service
Our gateway service is the only piece of our application we want to expose to the world. As such it will need security to ensure that only authenticated users can access sensitive information.
5.1. Security Configuration
Let’s create a SecurityConfig class like our discovery service and overwrite the methods with this content:
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build();
UserDetails adminUser = User.withUsername("admin")
.password(passwordEncoder().encode("admin"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, adminUser);
}
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(
new RedirectServerAuthenticationSuccessHandler("/home/index.html"))
.and().authorizeExchange()
.pathMatchers("/book-service/**", "/rating-service/**", "/login*", "/")
.permitAll()
.pathMatchers("/eureka/**").hasRole("ADMIN")
.anyExchange().authenticated().and()
.logout().and().csrf().disable().httpBasic(withDefaults());
return http.build();
}
}
This configuration is pretty straightforward. We declare a security filter with form login that secures a variety of endpoints.
The security on /eureka/** is to protect some static resources we will serve from our gateway service for the Eureka status page. If you are building the project with the article, copy the resource/static folder from the gateway project on Github to your project.
Now we have to add @EnableRedisWebSession:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}
The Spring Cloud Gateway filter automatically will grab the request as it is redirected after login and add the session key as a cookie in the header. This will propagate authentication to any backing service after login.
5.2. Authenticating With Config and Discovery Service
Let us add the following authentication properties to the bootstrap.properties file in src/main/resources of the gateway service:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
Next, let’s update our gateway.properties in our Git repository
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379
We have added session management to always generate sessions because we only have one security filter we can set that in the properties file. Next, we add our Redis host and server properties.
We can remove the serviceUrl.defaultZone property from the gateway.properties file in our configuration git repository. This value is duplicated in the bootstrap file.
Let’s commit the file to the Git repository, otherwise, the changes will not be detected.
6. 安全書籍服務
書籍服務服務器將存儲由各種用户控制的敏感信息。為了防止我們的系統中的受保護信息泄露,必須對該服務進行安全保護。
6.1. 安全配置
為了安全我們的書籍服務,我們將從網關複製 SecurityConfig 類,並覆蓋以下內容:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Autowired
public void registerAuthProvider(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests((auth) ->
auth.requestMatchers(HttpMethod.GET, "/books")
.permitAll()
.requestMatchers(HttpMethod.GET, "/books/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/books")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/books/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/books/*")
.hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.build();
}
}
6.2. 屬性
將這些屬性添加到 bootstrap.properties 文件中,該文件位於書籍服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們將這些屬性添加到我們的 book-service.properties 文件中,該文件位於我們的 Git 存儲庫中:
management.security.sessions=never
我們可以從 book-service.properties 文件中刪除 serviceUrl.defaultZone 屬性,該屬性位於我們的配置 Git 存儲庫中。此值在 bootstrap 文件中重複。
請提交這些更改,以便書籍服務可以拾取它們。
7. 保護評分服務
評分服務也需要進行保護。
7.1. 安全配置
為了保護我們的評分服務,我們將從網關複製 SecurityConfig 類並覆蓋方法內容如下:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService users() {
return new InMemoryUserDetailsManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.authorizeHttpRequests((auth) ->
auth.requestMatchers("^/ratings\\?bookId.*$")
.authenticated()
.requestMatchers(HttpMethod.POST, "/ratings")
.authenticated()
.requestMatchers(HttpMethod.PATCH, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/ratings")
.hasRole("ADMIN")
.anyRequest()
.authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
我們可以刪除 configureGlobal() 方法來自 gateway 服務。
7.2. 屬性
將以下屬性添加到 bootstrap.properties 文件中,位於評分服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們為評分服務添加屬性到 rating-service.properties 文件中,位於我們的 git 倉庫中:
management.security.sessions=never
我們可以刪除 serviceUrl.defaultZone 屬性來自 rating-service.properties 文件中,位於我們的配置 git 倉庫中。 此值在 bootstrap 文件中重複。
請提交這些更改,以便評分服務可以拾取它們。
8. Running and Testing
Start Redis and all the services for the application: config, discovery, gateway, book-service, rating-service . Now let’s test!
First, let’s create a test class in our gateway project and create a method for our test:
public class GatewayApplicationLiveTest {
@Test
public void testAccess() {
...
}
}
Next, let’s set up our test and validate that we can access our unprotected book-service/books resource by adding this code snippet inside our test method:
TestRestTemplate testRestTemplate = new TestRestTemplate();
String testUrl = "http://localhost:8080";
ResponseEntity<String> response = testRestTemplate
.getForEntity(testUrl + "/book-service/books", String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run this test and verify the results. If we see failures confirm that the entire application started successfully and that configurations were loaded from our configuration git repository.
Now let’s test that our users will be redirected to log in when visiting a protected resource as an unauthenticated user by appending this code to the end of the test method:
response = testRestTemplate
.getForEntity(testUrl + "/home/index.html", String.class);
Assert.assertEquals(HttpStatus.FOUND, response.getStatusCode());
Assert.assertEquals("http://localhost:8080/login", response.getHeaders()
.get("Location").get(0));
Run the test again and confirm that it succeeds.
Next, let’s actually log in and then use our session to access the user protected result:
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
now, let us extract the session from the cookie and propagate it to the following request:
String sessionCookie = response.getHeaders().get("Set-Cookie")
.get(0).split(";")[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
and request the protected resource:
response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run the test again to confirm the results.
Now, let’s try to access the admin section with the same session:
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
Run the test again, and as expected we are restricted from accessing admin areas as a plain old user.
The next test will validate that we can log in as the admin and access the admin protected resource:
form.clear();
form.add("username", "admin");
form.add("password", "admin");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
sessionCookie = response.getHeaders().get("Set-Cookie").get(0).split(";")[0];
headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
httpEntity = new HttpEntity<>(headers);
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Our test is getting big! But we can see when we run it that by logging in as the admin we gain access to the admin resource.
Our final test is accessing our discovery server through our gateway. To do this add this code to the end of our test:
response = testRestTemplate.exchange(testUrl + "/discovery",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Run this test one last time to confirm that everything is working. Success!!!
Did you miss that? Because we logged in on our gateway service and viewed content on our book, rating, and discovery services without having to log in on four separate servers!
By utilizing Spring Session to propagate our authentication object between servers we are able to log in once on the gateway and use that authentication to access controllers on any number of backing services.
9. 結論
雲安全無疑變得更加複雜。但藉助 Spring Security 和Spring Session,我們可以輕鬆解決這一關鍵問題。
我們現在擁有一個帶有云安全保護的應用程序。使用 Spring Cloud Gateway 和Spring Session ,我們可以在僅一個服務中對用户進行身份驗證,並將該身份驗證傳播到整個應用程序。這意味着我們可以輕鬆地將我們的應用程序分解為適當的領域,並對每個領域進行適當的保護。
讓我們添加一個安全過濾器來保護其他服務將使用的端點:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("discUser")
.password("{noop}discPassword").roles("SYSTEM");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(HttpMethod.GET, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.POST, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.PUT, "/eureka/**")
.hasRole("SYSTEM")
.requestMatchers(HttpMethod.DELETE, "/eureka/**")
.hasRole("SYSTEM")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
這將設置我們的服務具有“
- .sessionCreationPolicy – 告訴
Spring在用户登錄時始終創建一個會話 - .requestMatchers – 限制此過濾器應用於哪些端點
此安全過濾器配置了一個與發現服務相關的隔離身份驗證環境。
4.2. Securing Eureka Dashboard
由於我們的發現應用程序具有用於查看當前註冊服務的便捷 UI,讓我們使用第二個安全過濾器並將其與我們應用程序中的其餘部分的身份驗證鏈接起來。請注意,沒有@Order()標籤意味着此過濾器將作為最後評估的過濾器進行評估:
@Configuration
public static class AdminSecurityConfig {
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.httpBasic(basic -> basic.disable())
.authorizeRequests()
.requestMatchers(HttpMethod.GET, "/").hasRole("ADMIN")
.requestMatchers("/info", "/health").authenticated()
.anyRequest().denyAll()
.and().csrf(csrf -> csrf.disable());
}
}
將此配置類放在SecurityConfig類中。這將創建一個第二個安全過濾器,用於控制對我們 UI 的訪問。此過濾器具有一些不尋常的特性,讓我們來了解這些特性:
- httpBasic().disable() – 告訴
Spring為此過濾器禁用所有身份驗證程序
- sessionCreationPolicy – 我們將其設置為
NEVER,以指示我們需要在用户訪問受此過濾器保護的資源之前,才需要用户具有會話
此過濾器將不會設置用户會話,並且依賴於Redis來填充共享的安全上下文。因此,它取決於另一個服務,即網關,為提供身份驗證提供服務。
4.3. Authenticating With Config Service
在發現項目中,讓我們向bootstrap.properties中的 src/main/resources 中添加兩個屬性:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
這些屬性將允許發現服務在啓動時與配置服務進行身份驗證。
讓我們更新我們的discovery.properties在我們的 Git 倉庫中
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
我們已將基本的身份驗證憑據添加到我們的discovery服務,以便它可以與config服務進行通信。此外,我們配置Eureka為獨立模式運行,通過告訴我們的服務不要與自己註冊來實現。
讓我們提交該文件到git倉庫。否則,更改將不會被檢測到。
5. Securing Gateway Service
Our gateway service is the only piece of our application we want to expose to the world. As such it will need security to ensure that only authenticated users can access sensitive information.
5.1. Security Configuration
Let’s create a SecurityConfig class like our discovery service and overwrite the methods with this content:
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build();
UserDetails adminUser = User.withUsername("admin")
.password(passwordEncoder().encode("admin"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, adminUser);
}
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(
new RedirectServerAuthenticationSuccessHandler("/home/index.html"))
.and().authorizeExchange()
.pathMatchers("/book-service/**", "/rating-service/**", "/login*", "/")
.permitAll()
.pathMatchers("/eureka/**").hasRole("ADMIN")
.anyExchange().authenticated().and()
.logout().and().csrf().disable().httpBasic(withDefaults());
return http.build();
}
}
This configuration is pretty straightforward. We declare a security filter with form login that secures a variety of endpoints.
The security on /eureka/** is to protect some static resources we will serve from our gateway service for the Eureka status page. If you are building the project with the article, copy the resource/static folder from the gateway project on Github to your project.
Now we have to add @EnableRedisWebSession:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}
The Spring Cloud Gateway filter automatically will grab the request as it is redirected after login and add the session key as a cookie in the header. This will propagate authentication to any backing service after login.
5.2. Authenticating With Config and Discovery Service
Let us add the following authentication properties to the bootstrap.properties file in src/main/resources of the gateway service:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
Next, let’s update our gateway.properties in our Git repository
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379
We have added session management to always generate sessions because we only have one security filter we can set that in the properties file. Next, we add our Redis host and server properties.
We can remove the serviceUrl.defaultZone property from the gateway.properties file in our configuration git repository. This value is duplicated in the bootstrap file.
Let’s commit the file to the Git repository, otherwise, the changes will not be detected.
6. 安全書籍服務
書籍服務服務器將存儲由各種用户控制的敏感信息。為了防止我們的系統中的受保護信息泄露,必須對該服務進行安全保護。
6.1. 安全配置
為了安全我們的書籍服務,我們將從網關複製 SecurityConfig 類,並覆蓋以下內容:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Autowired
public void registerAuthProvider(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests((auth) ->
auth.requestMatchers(HttpMethod.GET, "/books")
.permitAll()
.requestMatchers(HttpMethod.GET, "/books/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/books")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/books/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/books/*")
.hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.build();
}
}
6.2. 屬性
將這些屬性添加到 bootstrap.properties 文件中,該文件位於書籍服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們將這些屬性添加到我們的 book-service.properties 文件中,該文件位於我們的 Git 存儲庫中:
management.security.sessions=never
我們可以從 book-service.properties 文件中刪除 serviceUrl.defaultZone 屬性,該屬性位於我們的配置 Git 存儲庫中。此值在 bootstrap 文件中重複。
請提交這些更改,以便書籍服務可以拾取它們。
7. 保護評分服務
評分服務也需要進行保護。
7.1. 安全配置
為了保護我們的評分服務,我們將從網關複製 SecurityConfig 類並覆蓋方法內容如下:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService users() {
return new InMemoryUserDetailsManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.authorizeHttpRequests((auth) ->
auth.requestMatchers("^/ratings\\?bookId.*$")
.authenticated()
.requestMatchers(HttpMethod.POST, "/ratings")
.authenticated()
.requestMatchers(HttpMethod.PATCH, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/ratings")
.hasRole("ADMIN")
.anyRequest()
.authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
我們可以刪除 configureGlobal() 方法來自 gateway 服務。
7.2. 屬性
將以下屬性添加到 bootstrap.properties 文件中,位於評分服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們為評分服務添加屬性到 rating-service.properties 文件中,位於我們的 git 倉庫中:
management.security.sessions=never
我們可以刪除 serviceUrl.defaultZone 屬性來自 rating-service.properties 文件中,位於我們的配置 git 倉庫中。 此值在 bootstrap 文件中重複。
請提交這些更改,以便評分服務可以拾取它們。
8. Running and Testing
Start Redis and all the services for the application: config, discovery, gateway, book-service, rating-service . Now let’s test!
First, let’s create a test class in our gateway project and create a method for our test:
public class GatewayApplicationLiveTest {
@Test
public void testAccess() {
...
}
}
Next, let’s set up our test and validate that we can access our unprotected book-service/books resource by adding this code snippet inside our test method:
TestRestTemplate testRestTemplate = new TestRestTemplate();
String testUrl = "http://localhost:8080";
ResponseEntity<String> response = testRestTemplate
.getForEntity(testUrl + "/book-service/books", String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run this test and verify the results. If we see failures confirm that the entire application started successfully and that configurations were loaded from our configuration git repository.
Now let’s test that our users will be redirected to log in when visiting a protected resource as an unauthenticated user by appending this code to the end of the test method:
response = testRestTemplate
.getForEntity(testUrl + "/home/index.html", String.class);
Assert.assertEquals(HttpStatus.FOUND, response.getStatusCode());
Assert.assertEquals("http://localhost:8080/login", response.getHeaders()
.get("Location").get(0));
Run the test again and confirm that it succeeds.
Next, let’s actually log in and then use our session to access the user protected result:
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
now, let us extract the session from the cookie and propagate it to the following request:
String sessionCookie = response.getHeaders().get("Set-Cookie")
.get(0).split(";")[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
and request the protected resource:
response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run the test again to confirm the results.
Now, let’s try to access the admin section with the same session:
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
Run the test again, and as expected we are restricted from accessing admin areas as a plain old user.
The next test will validate that we can log in as the admin and access the admin protected resource:
form.clear();
form.add("username", "admin");
form.add("password", "admin");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
sessionCookie = response.getHeaders().get("Set-Cookie").get(0).split(";")[0];
headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
httpEntity = new HttpEntity<>(headers);
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Our test is getting big! But we can see when we run it that by logging in as the admin we gain access to the admin resource.
Our final test is accessing our discovery server through our gateway. To do this add this code to the end of our test:
response = testRestTemplate.exchange(testUrl + "/discovery",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Run this test one last time to confirm that everything is working. Success!!!
Did you miss that? Because we logged in on our gateway service and viewed content on our book, rating, and discovery services without having to log in on four separate servers!
By utilizing Spring Session to propagate our authentication object between servers we are able to log in once on the gateway and use that authentication to access controllers on any number of backing services.
9. 結論
雲安全無疑變得更加複雜。但藉助 Spring Security 和Spring Session,我們可以輕鬆解決這一關鍵問題。
我們現在擁有一個帶有云安全保護的應用程序。使用 Spring Cloud Gateway 和Spring Session ,我們可以在僅一個服務中對用户進行身份驗證,並將該身份驗證傳播到整個應用程序。這意味着我們可以輕鬆地將我們的應用程序分解為適當的領域,並對每個領域進行適當的保護。
由於我們的發現應用程序具有用於查看當前註冊服務的便捷 UI,讓我們使用第二個安全過濾器並將其與我們應用程序中的其餘部分的身份驗證鏈接起來。請注意,沒有
@Configuration
public static class AdminSecurityConfig {
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.httpBasic(basic -> basic.disable())
.authorizeRequests()
.requestMatchers(HttpMethod.GET, "/").hasRole("ADMIN")
.requestMatchers("/info", "/health").authenticated()
.anyRequest().denyAll()
.and().csrf(csrf -> csrf.disable());
}
}
將此配置類放在
- httpBasic().disable() – 告訴
Spring為此過濾器禁用所有身份驗證程序 - sessionCreationPolicy – 我們將其設置為
NEVER,以指示我們需要在用户訪問受此過濾器保護的資源之前,才需要用户具有會話
此過濾器將不會設置用户會話,並且依賴於
4.3. Authenticating With Config Service
在發現項目中,讓我們向bootstrap.properties中的 src/main/resources 中添加兩個屬性:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
這些屬性將允許發現服務在啓動時與配置服務進行身份驗證。
讓我們更新我們的discovery.properties在我們的 Git 倉庫中
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
我們已將基本的身份驗證憑據添加到我們的discovery服務,以便它可以與config服務進行通信。此外,我們配置Eureka為獨立模式運行,通過告訴我們的服務不要與自己註冊來實現。
讓我們提交該文件到git倉庫。否則,更改將不會被檢測到。
5. Securing Gateway Service
Our gateway service is the only piece of our application we want to expose to the world. As such it will need security to ensure that only authenticated users can access sensitive information.
5.1. Security Configuration
Let’s create a SecurityConfig class like our discovery service and overwrite the methods with this content:
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build();
UserDetails adminUser = User.withUsername("admin")
.password(passwordEncoder().encode("admin"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, adminUser);
}
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(
new RedirectServerAuthenticationSuccessHandler("/home/index.html"))
.and().authorizeExchange()
.pathMatchers("/book-service/**", "/rating-service/**", "/login*", "/")
.permitAll()
.pathMatchers("/eureka/**").hasRole("ADMIN")
.anyExchange().authenticated().and()
.logout().and().csrf().disable().httpBasic(withDefaults());
return http.build();
}
}
This configuration is pretty straightforward. We declare a security filter with form login that secures a variety of endpoints.
The security on /eureka/** is to protect some static resources we will serve from our gateway service for the Eureka status page. If you are building the project with the article, copy the resource/static folder from the gateway project on Github to your project.
Now we have to add @EnableRedisWebSession:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}
The Spring Cloud Gateway filter automatically will grab the request as it is redirected after login and add the session key as a cookie in the header. This will propagate authentication to any backing service after login.
5.2. Authenticating With Config and Discovery Service
Let us add the following authentication properties to the bootstrap.properties file in src/main/resources of the gateway service:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
Next, let’s update our gateway.properties in our Git repository
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379
We have added session management to always generate sessions because we only have one security filter we can set that in the properties file. Next, we add our Redis host and server properties.
We can remove the serviceUrl.defaultZone property from the gateway.properties file in our configuration git repository. This value is duplicated in the bootstrap file.
Let’s commit the file to the Git repository, otherwise, the changes will not be detected.
6. 安全書籍服務
書籍服務服務器將存儲由各種用户控制的敏感信息。為了防止我們的系統中的受保護信息泄露,必須對該服務進行安全保護。
6.1. 安全配置
為了安全我們的書籍服務,我們將從網關複製 SecurityConfig 類,並覆蓋以下內容:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Autowired
public void registerAuthProvider(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests((auth) ->
auth.requestMatchers(HttpMethod.GET, "/books")
.permitAll()
.requestMatchers(HttpMethod.GET, "/books/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/books")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/books/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/books/*")
.hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.build();
}
}
6.2. 屬性
將這些屬性添加到 bootstrap.properties 文件中,該文件位於書籍服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們將這些屬性添加到我們的 book-service.properties 文件中,該文件位於我們的 Git 存儲庫中:
management.security.sessions=never
我們可以從 book-service.properties 文件中刪除 serviceUrl.defaultZone 屬性,該屬性位於我們的配置 Git 存儲庫中。此值在 bootstrap 文件中重複。
請提交這些更改,以便書籍服務可以拾取它們。
7. 保護評分服務
評分服務也需要進行保護。
7.1. 安全配置
為了保護我們的評分服務,我們將從網關複製 SecurityConfig 類並覆蓋方法內容如下:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService users() {
return new InMemoryUserDetailsManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.authorizeHttpRequests((auth) ->
auth.requestMatchers("^/ratings\\?bookId.*$")
.authenticated()
.requestMatchers(HttpMethod.POST, "/ratings")
.authenticated()
.requestMatchers(HttpMethod.PATCH, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/ratings")
.hasRole("ADMIN")
.anyRequest()
.authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
我們可以刪除 configureGlobal() 方法來自 gateway 服務。
7.2. 屬性
將以下屬性添加到 bootstrap.properties 文件中,位於評分服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們為評分服務添加屬性到 rating-service.properties 文件中,位於我們的 git 倉庫中:
management.security.sessions=never
我們可以刪除 serviceUrl.defaultZone 屬性來自 rating-service.properties 文件中,位於我們的配置 git 倉庫中。 此值在 bootstrap 文件中重複。
請提交這些更改,以便評分服務可以拾取它們。
8. Running and Testing
Start Redis and all the services for the application: config, discovery, gateway, book-service, rating-service . Now let’s test!
First, let’s create a test class in our gateway project and create a method for our test:
public class GatewayApplicationLiveTest {
@Test
public void testAccess() {
...
}
}
Next, let’s set up our test and validate that we can access our unprotected book-service/books resource by adding this code snippet inside our test method:
TestRestTemplate testRestTemplate = new TestRestTemplate();
String testUrl = "http://localhost:8080";
ResponseEntity<String> response = testRestTemplate
.getForEntity(testUrl + "/book-service/books", String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run this test and verify the results. If we see failures confirm that the entire application started successfully and that configurations were loaded from our configuration git repository.
Now let’s test that our users will be redirected to log in when visiting a protected resource as an unauthenticated user by appending this code to the end of the test method:
response = testRestTemplate
.getForEntity(testUrl + "/home/index.html", String.class);
Assert.assertEquals(HttpStatus.FOUND, response.getStatusCode());
Assert.assertEquals("http://localhost:8080/login", response.getHeaders()
.get("Location").get(0));
Run the test again and confirm that it succeeds.
Next, let’s actually log in and then use our session to access the user protected result:
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
now, let us extract the session from the cookie and propagate it to the following request:
String sessionCookie = response.getHeaders().get("Set-Cookie")
.get(0).split(";")[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
and request the protected resource:
response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run the test again to confirm the results.
Now, let’s try to access the admin section with the same session:
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
Run the test again, and as expected we are restricted from accessing admin areas as a plain old user.
The next test will validate that we can log in as the admin and access the admin protected resource:
form.clear();
form.add("username", "admin");
form.add("password", "admin");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
sessionCookie = response.getHeaders().get("Set-Cookie").get(0).split(";")[0];
headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
httpEntity = new HttpEntity<>(headers);
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Our test is getting big! But we can see when we run it that by logging in as the admin we gain access to the admin resource.
Our final test is accessing our discovery server through our gateway. To do this add this code to the end of our test:
response = testRestTemplate.exchange(testUrl + "/discovery",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Run this test one last time to confirm that everything is working. Success!!!
Did you miss that? Because we logged in on our gateway service and viewed content on our book, rating, and discovery services without having to log in on four separate servers!
By utilizing Spring Session to propagate our authentication object between servers we are able to log in once on the gateway and use that authentication to access controllers on any number of backing services.
9. 結論
雲安全無疑變得更加複雜。但藉助 Spring Security 和Spring Session,我們可以輕鬆解決這一關鍵問題。
我們現在擁有一個帶有云安全保護的應用程序。使用 Spring Cloud Gateway 和Spring Session ,我們可以在僅一個服務中對用户進行身份驗證,並將該身份驗證傳播到整個應用程序。這意味着我們可以輕鬆地將我們的應用程序分解為適當的領域,並對每個領域進行適當的保護。
在發現項目中,讓我們向
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
這些屬性將允許發現服務在啓動時與配置服務進行身份驗證。
讓我們更新我們的
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
我們已將基本的身份驗證憑據添加到我們的
讓我們提交該文件到
5. Securing Gateway Service
Our gateway service is the only piece of our application we want to expose to the world. As such it will need security to ensure that only authenticated users can access sensitive information.
5.1. Security Configuration
Let’s create a
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build();
UserDetails adminUser = User.withUsername("admin")
.password(passwordEncoder().encode("admin"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, adminUser);
}
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(
new RedirectServerAuthenticationSuccessHandler("/home/index.html"))
.and().authorizeExchange()
.pathMatchers("/book-service/**", "/rating-service/**", "/login*", "/")
.permitAll()
.pathMatchers("/eureka/**").hasRole("ADMIN")
.anyExchange().authenticated().and()
.logout().and().csrf().disable().httpBasic(withDefaults());
return http.build();
}
}
This configuration is pretty straightforward. We declare a security filter with form login that secures a variety of endpoints.
The security on /eureka/** is to protect some static resources we will serve from our gateway service for the
Now we have to add @EnableRedisWebSession:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}
The Spring Cloud Gateway filter automatically will grab the request as it is redirected after login and add the session key as a cookie in the header. This will propagate authentication to any backing service after login.
5.2. Authenticating With Config and Discovery Service
Let us add the following authentication properties to the
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
Next, let’s update our
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379
We have added session management to always generate sessions because we only have one security filter we can set that in the properties file. Next, we add our
We can remove the
Let’s commit the file to the Git repository, otherwise, the changes will not be detected.
6. 安全書籍服務
書籍服務服務器將存儲由各種用户控制的敏感信息。為了防止我們的系統中的受保護信息泄露,必須對該服務進行安全保護。
6.1. 安全配置
為了安全我們的書籍服務,我們將從網關複製 SecurityConfig 類,並覆蓋以下內容:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Autowired
public void registerAuthProvider(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests((auth) ->
auth.requestMatchers(HttpMethod.GET, "/books")
.permitAll()
.requestMatchers(HttpMethod.GET, "/books/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/books")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/books/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/books/*")
.hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.build();
}
}
6.2. 屬性
將這些屬性添加到 bootstrap.properties 文件中,該文件位於書籍服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們將這些屬性添加到我們的 book-service.properties 文件中,該文件位於我們的 Git 存儲庫中:
management.security.sessions=never
我們可以從 book-service.properties 文件中刪除 serviceUrl.defaultZone 屬性,該屬性位於我們的配置 Git 存儲庫中。此值在 bootstrap 文件中重複。
請提交這些更改,以便書籍服務可以拾取它們。
7. 保護評分服務
評分服務也需要進行保護。
7.1. 安全配置
為了保護我們的評分服務,我們將從網關複製 SecurityConfig 類並覆蓋方法內容如下:
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService users() {
return new InMemoryUserDetailsManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.authorizeHttpRequests((auth) ->
auth.requestMatchers("^/ratings\\?bookId.*$")
.authenticated()
.requestMatchers(HttpMethod.POST, "/ratings")
.authenticated()
.requestMatchers(HttpMethod.PATCH, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/ratings/*")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/ratings")
.hasRole("ADMIN")
.anyRequest()
.authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
我們可以刪除 configureGlobal() 方法來自 gateway 服務。
7.2. 屬性
將以下屬性添加到 bootstrap.properties 文件中,位於評分服務的 src/main/resources 目錄中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/
讓我們為評分服務添加屬性到 rating-service.properties 文件中,位於我們的 git 倉庫中:
management.security.sessions=never
我們可以刪除 serviceUrl.defaultZone 屬性來自 rating-service.properties 文件中,位於我們的配置 git 倉庫中。 此值在 bootstrap 文件中重複。
請提交這些更改,以便評分服務可以拾取它們。
8. Running and Testing
Start
First, let’s create a test class in our
public class GatewayApplicationLiveTest {
@Test
public void testAccess() {
...
}
}
Next, let’s set up our test and validate that we can access our unprotected
TestRestTemplate testRestTemplate = new TestRestTemplate();
String testUrl = "http://localhost:8080";
ResponseEntity<String> response = testRestTemplate
.getForEntity(testUrl + "/book-service/books", String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run this test and verify the results. If we see failures confirm that the entire application started successfully and that configurations were loaded from our configuration git repository.
Now let’s test that our users will be redirected to log in when visiting a protected resource as an unauthenticated user by appending this code to the end of the test method:
response = testRestTemplate
.getForEntity(testUrl + "/home/index.html", String.class);
Assert.assertEquals(HttpStatus.FOUND, response.getStatusCode());
Assert.assertEquals("http://localhost:8080/login", response.getHeaders()
.get("Location").get(0));
Run the test again and confirm that it succeeds.
Next, let’s actually log in and then use our session to access the user protected result:
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
now, let us extract the session from the cookie and propagate it to the following request:
String sessionCookie = response.getHeaders().get("Set-Cookie")
.get(0).split(";")[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
and request the protected resource:
response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Run the test again to confirm the results.
Now, let’s try to access the admin section with the same session:
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
Run the test again, and as expected we are restricted from accessing admin areas as a plain old user.
The next test will validate that we can log in as the admin and access the admin protected resource:
form.clear();
form.add("username", "admin");
form.add("password", "admin");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
sessionCookie = response.getHeaders().get("Set-Cookie").get(0).split(";")[0];
headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
httpEntity = new HttpEntity<>(headers);
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());
Our test is getting big! But we can see when we run it that by logging in as the admin we gain access to the admin resource.
Our final test is accessing our discovery server through our gateway. To do this add this code to the end of our test:
response = testRestTemplate.exchange(testUrl + "/discovery",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Run this test one last time to confirm that everything is working. Success!!!
Did you miss that? Because we logged in on our gateway service and viewed content on our book, rating, and discovery services without having to log in on four separate servers!
By utilizing
9. 結論
雲安全無疑變得更加複雜。但藉助 Spring Security 和Spring Session,我們可以輕鬆解決這一關鍵問題。
我們現在擁有一個帶有云安全保護的應用程序。使用 Spring Cloud Gateway 和Spring Session ,我們可以在僅一個服務中對用户進行身份驗證,並將該身份驗證傳播到整個應用程序。這意味着我們可以輕鬆地將我們的應用程序分解為適當的領域,並對每個領域進行適當的保護。