跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

当前所有心跳接口都可以匿名访问。公开的 GET /api/v1/health 只返回应用是否可用,可以继续作为课程页面的演示接口;执行检查、读取已保存状态等业务接口则不应该默认向所有调用方开放。

Spring Security 在请求进入 Spring MVC 之前通过 Servlet Filter 链完成安全处理。本章先使用 HTTP Basic 建立一套容易观察的本地认证,再分别说明授权、CORS、CSRF 和测试边界。HTTP Basic 只用于理解流程,不是后续登录系统或互联网 API 的最终认证方案。

1. 认证与授权

认证和授权解决两个不同问题:

概念回答的问题当前案例
认证 Authentication调用方是谁验证用户名和密码,建立 Authentication
授权 Authorization这个身份能做什么MONITOR 角色可以访问受保护的心跳接口

请求先经过认证,再使用已经建立的身份做授权判断:

security-flow.txt
1
HTTP 请求
2
-> Spring Security FilterChainProxy
3
-> 读取并验证凭据
4
-> 建立 SecurityContext
5
-> AuthorizationFilter 匹配访问规则
6
-> DispatcherServlet
7
-> Controller

匿名请求访问需要认证的资源时通常得到 401 Unauthorized,表示还没有有效身份;已经认证但权限不足时得到 403 Forbidden。业务异常处理器位于 MVC 内部,因此安全 Filter 链提前拒绝的结果不会进入 @RestControllerAdvice。

不要在 Controller 中手动读取密码并编写重复的 if。认证、凭据处理和访问拒绝属于安全基础设施,业务方法只在确实需要当前用户信息时读取 Authentication。

2. 添加依赖

在 pom.xml 中加入应用依赖和测试依赖:

pom.xml
01
<dependency>
02
<groupId>org.springframework.boot</groupId>
03
<artifactId>spring-boot-starter-security</artifactId>
04
</dependency>
05
06
<dependency>
07
<groupId>org.springframework.boot</groupId>
08
<artifactId>spring-boot-starter-security-test</artifactId>
09
<scope>test</scope>
10
</dependency>

应用 Starter 提供 Spring Security、Servlet 安全过滤链与自动配置。测试 Starter 提供 @WithMockUser、Security 的 MockMvc 请求处理器等测试工具,不会打入生产运行依赖。

只加入应用 Starter 而不编写自己的安全规则时,Spring Boot 会保护全部请求,并创建一个默认用户。这种默认行为适合提醒开发者安全依赖已经生效,但不代表项目已经完成访问设计。当前接口需要明确区分公开路径和受保护路径。

先为本地学习环境配置一个用户:

src/main/resources/application.yaml
01
spring:
02
security:
03
user:
04
name: ${APP_SECURITY_USER:monitor}
05
password: ${APP_SECURITY_PASSWORD:change-me}
06
roles: MONITOR
07
08
app:
09
cors:
10
allowed-origin: ${APP_CORS_ALLOWED_ORIGIN:https://springboot.usehook.cn}

默认值只方便本地课程练习。生产环境不能继续使用 change-me,也不能把真实密码提交到配置文件;应由 Secret 管理系统或受控环境变量提供。HTTP Basic 每次请求都会携带可还原的凭据,必须通过 HTTPS 传输。

3. Filter 链

创建安全配置,保留公开健康接口,只让 MONITOR 角色访问其余业务路径:

src/main/java/cn/usehook/spring/config/SecurityConfiguration.java
01
package cn.usehook.spring.config;
02
03
import org.springframework.context.annotation.Bean;
04
import org.springframework.context.annotation.Configuration;
05
import org.springframework.http.HttpMethod;
06
import org.springframework.security.config.Customizer;
07
import org.springframework.security.config.http.SessionCreationPolicy;
08
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
09
import org.springframework.security.web.SecurityFilterChain;
10
11
@Configuration(proxyBeanMethods = false)
12
public class SecurityConfiguration {
13
14
@Bean
15
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
16
http
17
.authorizeHttpRequests(authorize -> authorize
18
.requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll()
19
.requestMatchers("/actuator/health/**").permitAll()
20
.requestMatchers("/actuator/**").hasRole("MONITOR")
21
.requestMatchers("/api/v1/health/**").hasRole("MONITOR")
22
.anyRequest().denyAll()
23
)
24
.httpBasic(Customizer.withDefaults())
25
.sessionManagement(session -> session
26
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
27
)
28
.csrf(csrf -> csrf.disable())
29
.cors(Customizer.withDefaults());
30
31
return http.build();
32
}
33
}

授权规则按声明顺序匹配。具体的公开路径放在前面,较宽的 /api/v1/health/** 放在后面,最后使用 denyAll() 拒绝没有明确设计的路径。这样新增 Controller 不会因为忘记添加规则而意外公开。

hasRole("MONITOR") 实际匹配 ROLE_MONITOR 权限。角色适合表达一组粗粒度身份,业务继续细分后可以改用 hasAuthority() 检查更明确的权限名称。

这里配置无状态会话,服务端不为 HTTP Basic 身份保存登录 Session。/actuator/health/** 在 Actuator 依赖尚未加入时不会产生额外接口;后面的生产章节启用 Actuator 后,这条规则会允许平台匿名读取健康状态,其余实际暴露的管理端点需要 MONITOR 角色。端点没有加入 Actuator 暴露白名单时,安全规则不会凭空创建它。

公开接口不需要凭据:

terminal.bash
1
curl -i 'http://localhost:8080/api/v1/health'

受保护接口需要用户名和密码:

terminal.bash
1
curl -i -u monitor:change-me \
2
'http://localhost:8080/api/v1/health/status/postgresql'

命令行中的密码可能进入 Shell 历史或进程参数,只适合当前本地练习。真实运维脚本应从安全凭据来源读取,并避免在日志中输出认证头。

4. CORS 与 CSRF

CORS 决定浏览器中的某个来源是否可以读取另一个来源的响应,它不是用户认证,也不会阻止 curl 或服务器直接请求。现在安全规则集中到配置类,可以删除 HealthController 上的 @CrossOrigin,改为提供统一的 CORS 配置:

src/main/java/cn/usehook/spring/config/SecurityConfiguration.java
01
@Bean
02
CorsConfigurationSource corsConfigurationSource(
03
@Value("${app.cors.allowed-origin}") String allowedOrigin
04
) {
05
CorsConfiguration configuration = new CorsConfiguration();
06
configuration.setAllowedOrigins(List.of(allowedOrigin));
07
configuration.setAllowedMethods(List.of("GET", "POST"));
08
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
09
10
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
11
source.registerCorsConfiguration("/api/**", configuration);
12
return source;
13
}

这段代码需要引入 java.util.List、org.springframework.beans.factory.annotation.Value、org.springframework.web.cors.CorsConfiguration、CorsConfigurationSource 和 UrlBasedCorsConfigurationSource。安全 Filter 链中的 cors(Customizer.withDefaults()) 会使用这个 Bean。

CSRF 防止浏览器自动携带 Cookie 等凭据时,被恶意页面诱导执行修改操作。Spring Security 默认启用 CSRF;当前示例将其关闭,只适用于使用 curl 或服务端客户端的练习方式:调用方为每次请求显式添加 Authorization 头,课程网页仍然只调用公开 GET 接口。

关闭 CSRF 不能机械复制到所有项目。浏览器也可能缓存 HTTP Basic 凭据,因此不要使用浏览器登录对话框访问这组受保护接口。以后改成浏览器客户端、表单登录、Session Cookie 或其他可能自动携带凭据的方式时,应重新评估威胁模型,通常要恢复 CSRF 防护并让客户端发送有效 Token。CORS 允许某个来源读取响应,也不能替代 CSRF 防护。

5. 方法授权

URL 规则保护 HTTP 入口,但同一个 Service 还可能被定时任务、消息消费者或其他 Bean 调用。确实需要在业务能力入口再次授权时,可以启用方法安全:

src/main/java/cn/usehook/spring/config/SecurityConfiguration.java
1
@Configuration(proxyBeanMethods = false)
2
@EnableMethodSecurity
3
public class SecurityConfiguration {
4
// SecurityFilterChain 与 CORS Bean
5
}

然后在由其他 Bean 调用的公开方法上声明权限:

src/main/java/cn/usehook/spring/health/HealthService.java
1
@PreAuthorize("hasRole('MONITOR')")
2
public HealthStatus getRequired(String service) {
3
String normalizedService = normalizeService(service);
4
return repository.findByService(normalizedService)
5
.orElseThrow(() -> new HealthStatusNotFoundException(normalizedService));
6
}

这里需要引入 org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity 和 org.springframework.security.access.prepost.PreAuthorize。方法安全同样通过代理执行,因此对象必须由 Spring 容器管理,外部调用也必须经过代理。

当前项目只有一个 HTTP 调用入口,URL 规则已经足够表达需求,不必为了展示注解重复添加 @PreAuthorize。上面的写法用于说明边界:当相同业务能力存在非 HTTP 入口时,方法授权才产生额外价值。

6. 安全测试

加入 Security 后,@WebMvcTest 会把安全过滤链一起纳入测试。上一章中访问 POST 和已保存状态的用例现在都需要身份,否则请求会在到达 Controller 前得到 401。使用下面的完整内容替换原来的 HealthControllerWebTests:

src/test/java/cn/usehook/spring/controller/HealthControllerWebTests.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.config.SecurityConfiguration;
04
import cn.usehook.spring.health.HealthService;
05
import cn.usehook.spring.health.HealthStatus;
06
import org.junit.jupiter.api.Test;
07
import org.springframework.beans.factory.annotation.Autowired;
08
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
09
import org.springframework.context.annotation.Import;
10
import org.springframework.http.HttpStatus;
11
import org.springframework.http.MediaType;
12
import org.springframework.security.test.context.support.WithMockUser;
13
import org.springframework.test.context.bean.override.mockito.MockitoBean;
14
import org.springframework.test.web.servlet.assertj.MockMvcTester;
15
16
import static org.assertj.core.api.Assertions.assertThat;
17
import static org.mockito.BDDMockito.given;
18
import static org.mockito.BDDMockito.then;
19
20
@WebMvcTest(HealthController.class)
21
@Import(SecurityConfiguration.class)
22
class HealthControllerWebTests {
23
24
private final MockMvcTester mvc;
25
26
@MockitoBean
27
private HealthService healthService;
28
29
@Autowired
30
HealthControllerWebTests(MockMvcTester mvc) {
31
this.mvc = mvc;
32
}
33
34
@Test
35
void allowsAnonymousPublicHealthRequest() {
36
given(healthService.check())
37
.willReturn(new HealthStatus("UP", "spring"));
38
39
assertThat(mvc.get().uri("/api/v1/health"))
40
.hasStatusOk();
41
}
42
43
@Test
44
void rejectsAnonymousProtectedRequest() {
45
assertThat(mvc.get().uri("/api/v1/health/status/redis"))
46
.hasStatus(HttpStatus.UNAUTHORIZED);
47
}
48
49
@Test
50
@WithMockUser(roles = "MONITOR")
51
void allowsMonitorToReadStoredStatus() {
52
given(healthService.getRequired("redis"))
53
.willReturn(new HealthStatus("UP", "redis"));
54
55
assertThat(mvc.get().uri("/api/v1/health/status/redis"))
56
.hasStatusOk()
57
.bodyJson()
58
.extractingPath("$.data.service")
59
.asString()
60
.isEqualTo("redis");
61
}
62
63
@Test
64
@WithMockUser(roles = "MONITOR")
65
void acceptsAuthenticatedHealthCheck() {
66
given(healthService.check("PostgreSQL"))
67
.willReturn(new HealthStatus("UP", "postgresql"));
68
69
assertThat(mvc.post().uri("/api/v1/health/check")
70
.contentType(MediaType.APPLICATION_JSON)
71
.content("""
72
{"service":"PostgreSQL"}
73
"""))
74
.hasStatusOk()
75
.bodyJson()
76
.extractingPath("$.data.service")
77
.asString()
78
.isEqualTo("postgresql");
79
}
80
81
@Test
82
@WithMockUser(roles = "MONITOR")
83
void validatesAuthenticatedHealthCheck() {
84
assertThat(mvc.post().uri("/api/v1/health/check")
85
.contentType(MediaType.APPLICATION_JSON)
86
.content("""
87
{"service":" "}
88
"""))
89
.hasStatus(HttpStatus.BAD_REQUEST)
90
.bodyJson()
91
.extractingPath("$.errorCode")
92
.asString()
93
.isEqualTo("request_validation_failed");
94
95
then(healthService).shouldHaveNoInteractions();
96
}
97
}

@WithMockUser 直接建立测试身份,不会调用本地配置的用户名和密码;它适合验证授权结果,不是密码认证流程测试。公开接口测试则故意不添加身份,防止以后调整规则时误把平台探针或课程演示接口锁住。CSRF 在当前安全配置中已按本章限定条件关闭,因此 POST 测试不添加 CSRF Token;恢复 CSRF 后,修改类方法测试也必须携带有效 Token。

Spring Security 已经为 Web 边界建立默认拒绝、明确放行和身份测试。下一篇把本地 HTTP Basic 替换为 OAuth2 Resource Server,讲清 JWT 的签名验证、声明校验和 Scope 授权;随后再进入 PostgreSQL,让 Repository 从内存实现替换为持久化实现。