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。
接下來,在所有三個服務的同一目錄下添加一個 session 配置類,該類與主應用程序文件位於同一目錄中:
@EnableRedisHttpSession
public class SessionConfig
extends AbstractHttpSessionApplicationInitializer {
}請注意,對於網關服務,我們需要使用不同的註解,即 @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.password=configPassword
security.user.role=SYSTEM這將設置我們的服務使用發現進行登錄。此外,我們還通過 application.properties 文件配置了我們的安全設置。
現在,讓我們配置我們的發現服務。
4. 加強發現服務的安全性
我們的發現服務存儲了應用程序中所有服務的敏感信息,包括其位置。它還註冊了這些服務的全新實例。
如果惡意客户端獲得訪問權限,他們將瞭解我們系統中的所有服務的網絡位置,並且能夠將自己的惡意服務註冊到我們的應用程序中。因此,必須確保發現服務的安全性。
4.1. 安全配置
讓我們為其他服務將使用的端點添加一個安全過濾器:
@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 – 限制此過濾器應用於哪些端點
我們剛剛配置的安全過濾器,配置了一個僅適用於發現服務的隔離認證環境。
<h3><strong>4.2. 安全 Eureka 面板</strong></h3>
<p>由於我們的發現應用程序具有友好的 UI 用於查看當前註冊的服務,讓我們使用第二個安全過濾器來暴露它,並將此過濾器與我們應用程序的其餘部分的身份驗證關聯起來。請注意,由於沒有 <em title="Order">@Order()</em> 標記,因此這是一個在評估過程中最後執行的安全過濾器。</p>
@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 Security 為此過濾器禁用所有身份驗證程序
- sessionCreationPolicy – 我們將其設置為 NEVER 以指示我們要求用户在訪問此過濾器保護的資源之前已進行身份驗證
此過濾器將永遠不會設置用户會話,並依賴 Redis 來填充共享的安全上下文。因此,它依賴於另一個服務,網關,以提供身份驗證。
4.3. 使用配置服務進行身份驗證
在發現項目中,請將以下兩個屬性添加到 src/main/resources 目錄下的 <em>bootstrap.properties</em> 文件中:
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我們已將基本身份驗證憑據添加到我們的 發現服務中,以便其能夠與 配置服務進行通信。 此外,我們配置 Eureka 在獨立模式下運行,通過告知服務不要與自身註冊來實現。
讓我們將文件提交到 Git 存儲庫。 否則,更改將不會被檢測到。
5. 安全網關服務
我們的網關服務是我們應用程序中唯一需要暴露給世界的組件。因此,它需要具備安全措施,以確保只有經過身份驗證的用户才能訪問敏感信息。
5.1. 安全配置
讓我們創建一個類似於發現服務的 SecurityConfig 類,並使用以下內容覆蓋方法:
@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();
}
}此配置相當簡單。我們聲明瞭一個帶有表單登錄的安全過濾器,以保護各種端點。
對 /eureka/** 的安全保護是為了保護我們從網關服務提供的一些靜態資源,用於 Eureka 狀態頁面。如果您使用該文章構建項目,請從網關項目中的 resource/static 文件夾複製到您的項目:Github。
現在我們需要添加 @EnableRedisWebSession:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}Spring Cloud Gateway 過濾器會自動在重定向後登錄後獲取請求,並將會話鍵作為 HTTP 頭部中的 Cookie 添加。這會將身份驗證傳播到任何後端服務。
5.2. 使用配置和發現服務進行身份驗證
讓我們將以下身份驗證屬性添加到 gateway 服務中 src/main/resources 目錄下的 bootstrap.properties 文件中:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
http://discUser:discPassword@localhost:8082/eureka/接下來,讓我們更新我們的gateway.properties文件,位於我們的 Git 倉庫中。
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379我們已添加會話管理,以確保始終生成會話,因為我們只有一個安全過濾器可以設置在屬性文件中。接下來,我們添加了 Redis 主機和服務器屬性。
我們可以從 gateway.properties 文件中刪除 serviceUrl.defaultZone 屬性,該屬性在我們的配置 Git 倉庫中重複出現,並且在 bootstrap 文件中也存在。
讓我們將文件提交到 Git 倉庫,否則更改將不會被檢測到。
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我們可以從配置 Git 倉庫中的 book-service.properties 文件中移除 serviceUrl.defaultZone 屬性。該值在 bootstrap 文件中也已重複定義。
請務必提交這些更改,以便 book-service 能夠獲取它們。
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() 方法從 網關 服務中刪除。
7.2. 屬性
將以下屬性添加到 rating 服務中 src/main/resources 目錄下的 <em>bootstrap.properties</em> 文件中:
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我們可以從配置 Git 倉庫中的 rating-service..properties 文件中移除 serviceUrl.defaultZone 屬性。該值在 bootstrap 文件中也已重複出現。
請務必提交這些更改,以便評分服務能夠獲取它們。
8. 運行和測試
啓動 Redis 及其所有應用程序服務:config、discovery、gateway、book-service、rating-service。現在,讓我們開始測試!
首先,在 gateway 項目中創建一個測試類並創建一個測試方法:
public class GatewayApplicationLiveTest {
@Test
public void testAccess() {
...
}
}接下來,讓我們設置我們的測試,並驗證我們是否可以訪問未受保護的 /book-service/books 資源,通過在測試方法中添加以下代碼片段:
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());運行此測試並驗證結果。如果發現失敗,請確認整個應用程序已成功啓動,並且配置是從我們的配置 Git 倉庫加載的。
現在,讓我們測試當未認證用户訪問受保護資源時,用户是否會被重定向到登錄頁面。通過在測試方法的末尾添加以下代碼來實現:
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));再次運行測試,確認其成功。
接下來,我們實際登錄,然後使用我們的會話訪問受保護的結果:
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
.postForEntity(testUrl + "/login", form, String.class);
現在,讓我們從 Cookie 中提取會話,並將其傳播到後續請求:
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);
請求保護資源:
response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());再次運行測試以確認結果。
現在,讓我們嘗試使用相同的會話訪問管理部分。
response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());再次運行測試,正如預期的那樣,普通用户無法訪問管理區域。
下一次測試將驗證我們是否可以以管理員身份登錄並訪問受保護的管理資源:
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());我們的測試正在變得越來越大!但當我們運行它時,可以發現通過以管理員身份登錄,我們能夠訪問管理資源。
我們的最終測試是通過我們的網關訪問我們的發現服務器。為此,請將以下代碼添加到測試的末尾:
response = testRestTemplate.exchange(testUrl + "/discovery",
HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());請再次運行此測試以確認一切正常工作。成功!!!
您錯過了嗎?因為我們在網關服務上登錄,並查看了我們的書、評分和發現服務的內容,而無需在四台單獨的服務器上登錄!
通過利用 Spring Session 將我們的身份驗證對象在服務器之間傳播,我們可以在網關上一次登錄,並使用該身份驗證訪問任何數量的後端服務上的控制器。
9. 結論
雲安全無疑變得更加複雜。但藉助 Spring Security 和Spring Session,我們可以輕鬆解決這一關鍵問題。
我們現在擁有一個帶有云安全保護的應用程序。使用 Spring Cloud Gateway 和Spring Session ,我們可以在單個服務中對用户進行登錄,並將該身份驗證傳播到我們整個應用程序。這意味着我們可以輕鬆地將我們的應用程序分解為適當的領域,並根據需要安全地保護每個領域。