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

上一章使用 HTTP Basic 建立了最小认证流程。它适合观察 401、403 和授权规则,但客户端每次都要发送可还原的用户名与密码,应用也只能使用本地配置中的单个用户。面向多个客户端的 API 通常把登录和令牌签发交给独立认证服务器,业务应用只验证访问令牌。

本章把心跳应用改成 OAuth2 Resource Server,使用 JWT 格式的 Bearer Token 保护业务接口。重点是分清各参与方的职责,而不是在当前项目中自行实现密码登录和 Token 签发。

1. OAuth2 角色

OAuth2 场景至少要区分三类角色:

角色职责心跳案例中的位置
Client代表用户或自身调用 API管理后台、运维脚本
Authorization Server认证身份并签发访问令牌组织统一身份平台
Resource Server验证令牌并保护资源当前 Spring Boot 应用

调用过程如下:

oauth2-flow.txt
1
Client 向 Authorization Server 完成授权
2
-> 获得 Access Token
3
-> 携带 Authorization: Bearer <token> 请求心跳 API
4
-> Resource Server 验证签名、签发方、接收方和有效期
5
-> 根据 Scope 或 Authority 决定是否允许访问

Resource Server 不负责接收用户密码,也不应该提供一个「把用户名转换成 JWT」的临时接口。令牌的获取流程取决于客户端类型和认证服务器能力,例如用户交互可以使用 Authorization Code,服务之间可以使用 Client Credentials。它们属于认证服务器和客户端的契约。

上一章的 HTTP Basic 是一种独立认证方式,不是 OAuth2 登录的第一步。切换到 Resource Server 后,受保护接口只接受 Bearer Token;本地用户名和密码配置不再作为业务 API 的认证来源。

2. JWT

JWT 是一种紧凑的令牌表示,由三个使用点号连接的部分组成:

jwt-shape.txt
1
base64url(header).base64url(payload).base64url(signature)
部分常见内容用途
Headeralg、kid表示签名算法和密钥标识
Payloadiss、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:

pom.xml
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:

src/main/resources/application.yaml
1
spring:
2
security:
3
oauth2:
4
resourceserver:
5
jwt:
6
issuer-uri: ${AUTH_ISSUER_URI}
7
audiences: ${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:

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.annotation.web.builders.HttpSecurity;
08
import org.springframework.security.config.http.SessionCreationPolicy;
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/**").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 -> session
26
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
27
)
28
.csrf(csrf -> csrf.disable())
29
.cors(Customizer.withDefaults());
30
31
return 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 调用受保护接口:

terminal.bash
1
curl -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:

OperatorController.java
1
@GetMapping("/api/v1/operator")
2
OperatorResponse operator(@AuthenticationPrincipal Jwt jwt) {
3
return new OperatorResponse(jwt.getSubject());
4
}
5
6
record 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:

src/test/java/cn/usehook/spring/controller/HealthJwtSecurityTests.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.security.core.authority.SimpleGrantedAuthority;
11
import org.springframework.security.oauth2.jwt.JwtDecoder;
12
import org.springframework.test.context.bean.override.mockito.MockitoBean;
13
import org.springframework.test.web.servlet.MockMvc;
14
15
import static org.mockito.BDDMockito.given;
16
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
17
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
18
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
19
20
@WebMvcTest(HealthController.class)
21
@Import(SecurityConfiguration.class)
22
class HealthJwtSecurityTests {
23
24
@Autowired
25
private MockMvc mvc;
26
27
@MockitoBean
28
private HealthService healthService;
29
30
@MockitoBean
31
private JwtDecoder jwtDecoder;
32
33
@Test
34
void allowsMonitorScope() throws Exception {
35
given(healthService.getRequired("postgresql"))
36
.willReturn(new HealthStatus("UP", "postgresql"));
37
38
mvc.perform(get("/api/v1/health/status/postgresql")
39
.with(jwt().authorities(
40
new SimpleGrantedAuthority("SCOPE_monitor")
41
)))
42
.andExpect(status().isOk());
43
}
44
45
@Test
46
void rejectsMissingMonitorScope() throws Exception {
47
mvc.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 连起来。