上一章使用 HTTP Basic 建立了最小认证流程。它适合观察 401、403 和授权规则,但客户端每次都要发送可还原的用户名与密码,应用也只能使用本地配置中的单个用户。面向多个客户端的 API 通常把登录和令牌签发交给独立认证服务器,业务应用只验证访问令牌。
本章把心跳应用改成 OAuth2 Resource Server,使用 JWT 格式的 Bearer Token 保护业务接口。重点是分清各参与方的职责,而不是在当前项目中自行实现密码登录和 Token 签发。
1. OAuth2 角色
OAuth2 场景至少要区分三类角色:
| 角色 | 职责 | 心跳案例中的位置 |
|---|---|---|
| Client | 代表用户或自身调用 API | 管理后台、运维脚本 |
| Authorization Server | 认证身份并签发访问令牌 | 组织统一身份平台 |
| Resource Server | 验证令牌并保护资源 | 当前 Spring Boot 应用 |
调用过程如下:
1Client 向 Authorization Server 完成授权2-> 获得 Access Token3-> 携带 Authorization: Bearer <token> 请求心跳 API4-> Resource Server 验证签名、签发方、接收方和有效期5-> 根据 Scope 或 Authority 决定是否允许访问
Resource Server 不负责接收用户密码,也不应该提供一个「把用户名转换成 JWT」的临时接口。令牌的获取流程取决于客户端类型和认证服务器能力,例如用户交互可以使用 Authorization Code,服务之间可以使用 Client Credentials。它们属于认证服务器和客户端的契约。
上一章的 HTTP Basic 是一种独立认证方式,不是 OAuth2 登录的第一步。切换到 Resource Server 后,受保护接口只接受 Bearer Token;本地用户名和密码配置不再作为业务 API 的认证来源。
2. JWT
JWT 是一种紧凑的令牌表示,由三个使用点号连接的部分组成:
1base64url(header).base64url(payload).base64url(signature)
| 部分 | 常见内容 | 用途 |
|---|---|---|
| Header | alg、kid | 表示签名算法和密钥标识 |
| Payload | iss、sub、aud、exp、scope | 保存声明 Claims |
| Signature | 前两部分的数字签名 | 检测内容是否被篡改并验证签发者 |
Header 和 Payload 使用 Base64URL 编码,不等于加密。拿到 Token 的客户端通常能够读取其中声明,因此不能把密码、数据库连接串或其他秘密放进 JWT。
Resource Server 至少应验证:
- 签名是否来自受信任密钥;
iss是否为预期签发方;aud是否包含当前 API;exp是否尚未过期;nbf存在时是否已经到生效时间。
只把 Payload 解码成 JSON,没有完成任何认证。攻击者可以自行构造前两部分,只有签名和声明校验全部通过后,Spring Security 才会建立可信的 Authentication。
JWT 适合本地验证和跨服务传递,但已经签发的 Token 通常不能即时撤销。有效期、密钥轮换、吊销需求和敏感权限变化需要由完整安全方案共同决定,不能因为令牌格式是 JWT 就忽略生命周期。
3. 资源服务器
在 pom.xml 中加入 Resource Server Starter:
1<dependency>2<groupId>org.springframework.boot</groupId>3<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>4</dependency>
Starter 会提供 Bearer Token 过滤器、JWT 解码器集成和相关自动配置。项目已有的 spring-boot-starter-security 可以继续保留,版本仍由 Spring Boot Parent 统一管理。
通过部署环境提供认证服务器地址和当前 API 的 Audience:
1spring:2security:3oauth2:4resourceserver:5jwt:6issuer-uri: ${AUTH_ISSUER_URI}7audiences: ${AUTH_AUDIENCE:https://springboot-api.usehook.cn}
issuer-uri 必须与 Token 的 iss 声明一致。Spring Security 通过认证服务器元数据发现 JWK Set,取得公钥并按 kid 选择验证密钥。audiences 限制 Token 必须是签发给当前 API 的,避免另一个系统的有效 Token 被拿来访问本服务。
只有认证服务器不能提供标准元数据时,才考虑显式配置 jwk-set-uri 或公钥位置。把私钥放进 Resource Server 是错误边界:验证签名只需要公钥,签发令牌的私钥应留在认证服务器。
应用需要连接可用的认证服务器才能完成真实 Token 验证。教程代码不虚构一个固定的生产地址;本地开发可以使用团队身份服务的开发租户,测试则通过 Spring Security 测试支持建立受控 JWT 身份。
4. 授权规则
把上一章的 HTTP Basic 改为 JWT Resource Server:
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.annotation.web.builders.HttpSecurity;08import org.springframework.security.config.http.SessionCreationPolicy;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/**").hasAuthority("SCOPE_monitor")21.requestMatchers("/api/v1/health/**").hasAuthority("SCOPE_monitor")22.anyRequest().denyAll()23)24.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))25.sessionManagement(session -> session26.sessionCreationPolicy(SessionCreationPolicy.STATELESS)27)28.csrf(csrf -> csrf.disable())29.cors(Customizer.withDefaults());3031return http.build();32}33}
上一章定义的 CorsConfigurationSource Bean 继续保留在这个配置类中,这里不重复展示。认证方式从 httpBasic() 改成 oauth2ResourceServer(),CORS 允许来源和请求头的规则仍然有效。
标准 scope 或 scp 声明默认转换成带 SCOPE_ 前缀的 Authority。例如 Token 中的 scope: "monitor" 会得到 SCOPE_monitor,因此这里使用 hasAuthority(),不再使用上一章面向角色的 hasRole("MONITOR")。
认证服务器如果使用自定义 roles 或 authorities 声明,需要用 JwtAuthenticationConverter 明确转换规则,不能只修改字符串前缀就期待 Spring 自动猜测。权限命名是认证服务器与资源服务器共同维护的契约。
当前 API 使用无状态 Bearer Token,并要求客户端主动设置 Authorization 头,因此继续关闭 CSRF。以后如果把 Token 放进浏览器自动携带的 Cookie,仍然要重新启用并设计 CSRF 防护;JWT 格式本身不会消除 CSRF 风险。
5. 请求身份
通过 Bearer Token 调用受保护接口:
1curl -i 'http://localhost:8080/api/v1/health/status/postgresql' \2-H "Authorization: Bearer ${ACCESS_TOKEN}"
没有 Token、Token 过期或签名无效时返回 401。Token 有效但缺少 monitor Scope 时返回 403。两类结果都由安全 Filter 链产生,不会进入 MVC 的 @RestControllerAdvice。
确实需要读取当前主体时,可以在 Controller 参数中声明 Jwt:
1@GetMapping("/api/v1/operator")2OperatorResponse operator(@AuthenticationPrincipal Jwt jwt) {3return new OperatorResponse(jwt.getSubject());4}56record OperatorResponse(String subject) {}
这里需要引入 org.springframework.security.core.annotation.AuthenticationPrincipal 和 org.springframework.security.oauth2.jwt.Jwt。sub 通常表示主体稳定标识,但具体语义仍由认证服务器定义。业务数据需要关联用户时,应保存经过约定的稳定 ID,不要把显示名称当作永久主键。
如果业务代码只需要判断权限,不需要主体声明,就不要为了证明认证存在而把 Jwt 传入每一层。授权规则留在安全边界,Service 参数继续表达业务输入。
6. JWT 测试
MVC 授权测试不需要启动真实认证服务器,也不需要在测试中生成和签名字符串 Token。jwt() RequestPostProcessor 可以直接为模拟请求建立经过认证的 JWT 主体,让测试聚焦访问规则。
下面创建独立的安全测试,并为配置提供一个 Mock JwtDecoder:
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.security.core.authority.SimpleGrantedAuthority;11import org.springframework.security.oauth2.jwt.JwtDecoder;12import org.springframework.test.context.bean.override.mockito.MockitoBean;13import org.springframework.test.web.servlet.MockMvc;1415import static org.mockito.BDDMockito.given;16import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;17import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;1920@WebMvcTest(HealthController.class)21@Import(SecurityConfiguration.class)22class HealthJwtSecurityTests {2324@Autowired25private MockMvc mvc;2627@MockitoBean28private HealthService healthService;2930@MockitoBean31private JwtDecoder jwtDecoder;3233@Test34void allowsMonitorScope() throws Exception {35given(healthService.getRequired("postgresql"))36.willReturn(new HealthStatus("UP", "postgresql"));3738mvc.perform(get("/api/v1/health/status/postgresql")39.with(jwt().authorities(40new SimpleGrantedAuthority("SCOPE_monitor")41)))42.andExpect(status().isOk());43}4445@Test46void rejectsMissingMonitorScope() throws Exception {47mvc.perform(get("/api/v1/health/status/postgresql").with(jwt()))48.andExpect(status().isForbidden());49}50}
@MockitoBean JwtDecoder 让测试上下文能够创建 Resource Server 配置;jwt() 直接建立测试认证,不会调用这个 Decoder。第一项测试显式提供 SCOPE_monitor,第二项使用默认 Scope 并验证权限不足。
这种 MVC 测试证明授权规则和请求身份转换,但不证明某个真实认证服务器签发的 Token 一定能通过。部署验证还要使用测试租户签发的 Token 检查 iss、aud、密钥发现和时钟配置。
现在业务 API 已经从本地用户名密码切换为外部身份令牌。下一部分进入 PostgreSQL,先建立稳定的数据访问边界;完成全部数据与外部调用能力后,再用真实端口的端到端测试把 HTTP、JWT、Service 和 PostgreSQL 连起来。