当前所有心跳接口都可以匿名访问。公开的 GET /api/v1/health 只返回应用是否可用,可以继续作为课程页面的演示接口;执行检查、读取已保存状态等业务接口则不应该默认向所有调用方开放。
Spring Security 在请求进入 Spring MVC 之前通过 Servlet Filter 链完成安全处理。本章先使用 HTTP Basic 建立一套容易观察的本地认证,再分别说明授权、CORS、CSRF 和测试边界。HTTP Basic 只用于理解流程,不是后续登录系统或互联网 API 的最终认证方案。
1. 认证与授权
认证和授权解决两个不同问题:
| 概念 | 回答的问题 | 当前案例 |
|---|---|---|
| 认证 Authentication | 调用方是谁 | 验证用户名和密码,建立 Authentication |
| 授权 Authorization | 这个身份能做什么 | MONITOR 角色可以访问受保护的心跳接口 |
请求先经过认证,再使用已经建立的身份做授权判断:
1HTTP 请求2-> Spring Security FilterChainProxy3-> 读取并验证凭据4-> 建立 SecurityContext5-> AuthorizationFilter 匹配访问规则6-> DispatcherServlet7-> Controller
匿名请求访问需要认证的资源时通常得到 401 Unauthorized,表示还没有有效身份;已经认证但权限不足时得到 403 Forbidden。业务异常处理器位于 MVC 内部,因此安全 Filter 链提前拒绝的结果不会进入 @RestControllerAdvice。
不要在 Controller 中手动读取密码并编写重复的 if。认证、凭据处理和访问拒绝属于安全基础设施,业务方法只在确实需要当前用户信息时读取 Authentication。
2. 添加依赖
在 pom.xml 中加入应用依赖和测试依赖:
01<dependency>02<groupId>org.springframework.boot</groupId>03<artifactId>spring-boot-starter-security</artifactId>04</dependency>0506<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 会保护全部请求,并创建一个默认用户。这种默认行为适合提醒开发者安全依赖已经生效,但不代表项目已经完成访问设计。当前接口需要明确区分公开路径和受保护路径。
先为本地学习环境配置一个用户:
01spring:02security:03user:04name: ${APP_SECURITY_USER:monitor}05password: ${APP_SECURITY_PASSWORD:change-me}06roles: MONITOR0708app:09cors:10allowed-origin: ${APP_CORS_ALLOWED_ORIGIN:https://springboot.usehook.cn}
默认值只方便本地课程练习。生产环境不能继续使用 change-me,也不能把真实密码提交到配置文件;应由 Secret 管理系统或受控环境变量提供。HTTP Basic 每次请求都会携带可还原的凭据,必须通过 HTTPS 传输。
3. Filter 链
创建安全配置,保留公开健康接口,只让 MONITOR 角色访问其余业务路径:
01package cn.usehook.spring.config;0203import org.springframework.context.annotation.Bean;04import org.springframework.context.annotation.Configuration;05import org.springframework.http.HttpMethod;06import org.springframework.security.config.Customizer;07import org.springframework.security.config.http.SessionCreationPolicy;08import org.springframework.security.config.annotation.web.builders.HttpSecurity;09import org.springframework.security.web.SecurityFilterChain;1011@Configuration(proxyBeanMethods = false)12public class SecurityConfiguration {1314@Bean15SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {16http17.authorizeHttpRequests(authorize -> authorize18.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 -> session26.sessionCreationPolicy(SessionCreationPolicy.STATELESS)27)28.csrf(csrf -> csrf.disable())29.cors(Customizer.withDefaults());3031return http.build();32}33}
授权规则按声明顺序匹配。具体的公开路径放在前面,较宽的 /api/v1/health/** 放在后面,最后使用 denyAll() 拒绝没有明确设计的路径。这样新增 Controller 不会因为忘记添加规则而意外公开。
hasRole("MONITOR") 实际匹配 ROLE_MONITOR 权限。角色适合表达一组粗粒度身份,业务继续细分后可以改用 hasAuthority() 检查更明确的权限名称。
这里配置无状态会话,服务端不为 HTTP Basic 身份保存登录 Session。/actuator/health/** 在 Actuator 依赖尚未加入时不会产生额外接口;后面的生产章节启用 Actuator 后,这条规则会允许平台匿名读取健康状态,其余实际暴露的管理端点需要 MONITOR 角色。端点没有加入 Actuator 暴露白名单时,安全规则不会凭空创建它。
公开接口不需要凭据:
1curl -i 'http://localhost:8080/api/v1/health'
受保护接口需要用户名和密码:
1curl -i -u monitor:change-me \2'http://localhost:8080/api/v1/health/status/postgresql'
命令行中的密码可能进入 Shell 历史或进程参数,只适合当前本地练习。真实运维脚本应从安全凭据来源读取,并避免在日志中输出认证头。
4. CORS 与 CSRF
CORS 决定浏览器中的某个来源是否可以读取另一个来源的响应,它不是用户认证,也不会阻止 curl 或服务器直接请求。现在安全规则集中到配置类,可以删除 HealthController 上的 @CrossOrigin,改为提供统一的 CORS 配置:
01@Bean02CorsConfigurationSource corsConfigurationSource(03@Value("${app.cors.allowed-origin}") String allowedOrigin04) {05CorsConfiguration configuration = new CorsConfiguration();06configuration.setAllowedOrigins(List.of(allowedOrigin));07configuration.setAllowedMethods(List.of("GET", "POST"));08configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));0910UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();11source.registerCorsConfiguration("/api/**", configuration);12return 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 调用。确实需要在业务能力入口再次授权时,可以启用方法安全:
1@Configuration(proxyBeanMethods = false)2@EnableMethodSecurity3public class SecurityConfiguration {4// SecurityFilterChain 与 CORS Bean5}
然后在由其他 Bean 调用的公开方法上声明权限:
1@PreAuthorize("hasRole('MONITOR')")2public HealthStatus getRequired(String service) {3String normalizedService = normalizeService(service);4return 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:
01package cn.usehook.spring.controller;0203import cn.usehook.spring.config.SecurityConfiguration;04import cn.usehook.spring.health.HealthService;05import cn.usehook.spring.health.HealthStatus;06import org.junit.jupiter.api.Test;07import org.springframework.beans.factory.annotation.Autowired;08import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;09import org.springframework.context.annotation.Import;10import org.springframework.http.HttpStatus;11import org.springframework.http.MediaType;12import org.springframework.security.test.context.support.WithMockUser;13import org.springframework.test.context.bean.override.mockito.MockitoBean;14import org.springframework.test.web.servlet.assertj.MockMvcTester;1516import static org.assertj.core.api.Assertions.assertThat;17import static org.mockito.BDDMockito.given;18import static org.mockito.BDDMockito.then;1920@WebMvcTest(HealthController.class)21@Import(SecurityConfiguration.class)22class HealthControllerWebTests {2324private final MockMvcTester mvc;2526@MockitoBean27private HealthService healthService;2829@Autowired30HealthControllerWebTests(MockMvcTester mvc) {31this.mvc = mvc;32}3334@Test35void allowsAnonymousPublicHealthRequest() {36given(healthService.check())37.willReturn(new HealthStatus("UP", "spring"));3839assertThat(mvc.get().uri("/api/v1/health"))40.hasStatusOk();41}4243@Test44void rejectsAnonymousProtectedRequest() {45assertThat(mvc.get().uri("/api/v1/health/status/redis"))46.hasStatus(HttpStatus.UNAUTHORIZED);47}4849@Test50@WithMockUser(roles = "MONITOR")51void allowsMonitorToReadStoredStatus() {52given(healthService.getRequired("redis"))53.willReturn(new HealthStatus("UP", "redis"));5455assertThat(mvc.get().uri("/api/v1/health/status/redis"))56.hasStatusOk()57.bodyJson()58.extractingPath("$.data.service")59.asString()60.isEqualTo("redis");61}6263@Test64@WithMockUser(roles = "MONITOR")65void acceptsAuthenticatedHealthCheck() {66given(healthService.check("PostgreSQL"))67.willReturn(new HealthStatus("UP", "postgresql"));6869assertThat(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}8081@Test82@WithMockUser(roles = "MONITOR")83void validatesAuthenticatedHealthCheck() {84assertThat(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");9495then(healthService).shouldHaveNoInteractions();96}97}
@WithMockUser 直接建立测试身份,不会调用本地配置的用户名和密码;它适合验证授权结果,不是密码认证流程测试。公开接口测试则故意不添加身份,防止以后调整规则时误把平台探针或课程演示接口锁住。CSRF 在当前安全配置中已按本章限定条件关闭,因此 POST 测试不添加 CSRF Token;恢复 CSRF 后,修改类方法测试也必须携带有效 Token。
Spring Security 已经为 Web 边界建立默认拒绝、明确放行和身份测试。下一篇把本地 HTTP Basic 替换为 OAuth2 Resource Server,讲清 JWT 的签名验证、声明校验和 Scope 授权;随后再进入 PostgreSQL,让 Repository 从内存实现替换为持久化实现。