1. 概述
在上一篇Spring Cloud服务搭建指南中,我们构建了一个基础的Spring Cloud应用。本文将展示如何为它添加安全防护。
我们将采用Spring Security结合Spring Session和Redis来实现会话共享。这种方法配置简单,且易于扩展到多种业务场景。如果你还不熟悉Spring Session,建议先阅读这篇入门文章。
会话共享让我们能在网关服务中完成用户登录,并将认证状态传播到系统中的任意其他服务。
如果你对Redis或Spring Security不熟悉,建议先快速复习这些概念。虽然本文大部分代码可直接复制使用,但理解底层原理仍是必要的。
2. Maven配置
首先为系统中每个模块添加spring-boot-starter-security
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
由于使用Spring依赖管理,我们可以省略spring-boot-starter
依赖的版本号。
接下来,为每个应用添加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 {
}
⚠️ 注意:网关服务需使用@EnableRedisWebSession
注解而非@EnableRedisHttpSession
。
最后在Git仓库的三个*.properties
文件中添加这些属性:
spring.redis.host=localhost
spring.redis.port=6379
现在我们开始各服务的具体安全配置。
3. 保护配置中心
配置中心包含数据库连接和API密钥等敏感信息,绝不能泄露。让我们直接开始保护它。
在配置中心的src/main/resources/application.properties
中添加安全属性:
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
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
auth.inMemoryAuthentication().withUser("discUser")
.password("discPassword").roles("SYSTEM");
}
@Override
protected void configure(HttpSecurity http) {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
.and().requestMatchers().antMatchers("/eureka/**")
.and().authorizeRequests().antMatchers("/eureka/**")
.hasRole("SYSTEM").anyRequest().denyAll().and()
.httpBasic().and().csrf().disable();
}
}
关键点解析:
@Order(1)
:告诉Spring优先加载此安全过滤器sessionCreationPolicy
:强制在用户登录时创建会话requestMatchers
:限制过滤器仅应用于指定接口
这个过滤器为注册中心创建了独立的认证环境。
4.2 保护Eureka控制台
注册中心提供了查看已注册服务的UI界面,我们通过第二个安全过滤器暴露它。注意没有@Order()
注解意味着这是最后评估的过滤器:
@Configuration
public static class AdminSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) {
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER)
.and().httpBasic().disable().authorizeRequests()
.antMatchers(HttpMethod.GET, "/").hasRole("ADMIN")
.antMatchers("/info", "/health").authenticated().anyRequest()
.denyAll().and().csrf().disable();
}
}
将此配置类添加到SecurityConfig
类内部。特殊之处:
httpBasic().disable()
:禁用此过滤器的所有认证流程sessionCreationPolicy
:设为NEVER
,要求用户必须已通过其他服务认证
此过滤器从不创建会话,依赖Redis填充共享的安全上下文,因此依赖网关服务提供认证。
4.3 认证配置中心
在注册中心项目的src/main/resources/bootstrap.properties
中添加:
spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
这些属性让注册中心能在启动时认证配置中心。
更新Git仓库中的discovery.properties
:
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状态页静态资源(需从GitHub复制resource/static
文件夹)
添加会话配置:
@Configuration
@EnableRedisWebSession
public class SessionConfig {}
Spring Cloud Gateway过滤器会自动在登录重定向后获取请求,将会话密钥作为Cookie添加到请求头,实现认证向后端服务的传播。
5.2 认证配置中心和注册中心
在网关服务的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/
更新Git仓库中的gateway.properties
:
management.security.sessions=always
spring.redis.host=localhost
spring.redis.port=6379
- 设置会话管理始终生成会话(因只有一个安全过滤器)
- 添加Redis主机和端口配置
可从配置Git仓库的gateway.properties
中移除serviceUrl.defaultZone
属性(与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.antMatchers(HttpMethod.GET, "/books").permitAll()
.antMatchers(HttpMethod.GET, "/books/*").permitAll()
.antMatchers(HttpMethod.POST, "/books").hasRole("ADMIN")
.antMatchers(HttpMethod.PATCH, "/books/*").hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE, "/books/*").hasRole("ADMIN"))
.csrf().disable().build();
}
}
6.2 属性配置
在图书服务的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/
在Git仓库的book-service.properties
中添加:
management.security.sessions=never
可从配置Git仓库的book-service.properties
中移除serviceUrl.defaultZone
属性(与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.regexMatchers("^/ratings\\?bookId.*$")
.authenticated()
.antMatchers(HttpMethod.POST, "/ratings").authenticated()
.antMatchers(HttpMethod.PATCH, "/ratings/*").hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE, "/ratings/*").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/ratings").hasRole("ADMIN")
.anyRequest().authenticated()).httpBasic()
.and().csrf().disable().build();
}
}
可删除网关服务中的configureGlobal()
方法。
7.2 属性配置
在评分服务的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/
在Git仓库的rating-service.properties
中添加:
management.security.sessions=never
可从配置Git仓库的rating-service.properties
中移除serviceUrl.defaultZone
属性。
⚠️ 记得提交变更,评分服务才能获取新配置。
8. 运行与测试
启动Redis和所有服务:config, discovery, gateway, book-service, rating-service。开始测试!
在网关项目创建测试类:
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,用户只需在一个服务登录,认证状态即可传播到整个应用。这意味着我们可以将应用拆分为合适的领域,并根据需要分别保护它们。
完整源代码可在GitHub获取。