1. 概述

在上一篇Spring Cloud服务搭建指南中,我们构建了一个基础的Spring Cloud应用。本文将展示如何为它添加安全防护。

我们将采用Spring Security结合Spring SessionRedis来实现会话共享。这种方法配置简单,且易于扩展到多种业务场景。如果你还不熟悉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-sessionspring-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:discoverygatewaybook-servicerating-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获取。


原始标题:Securing Spring Cloud Services