OAuth 2.0 和 OpenID Connect 实例详解(Spring Boot 3)
1. 概述
OAuth 2.0 和 OpenID Connect(OIDC)通常一起被提及,所以很多人把它们看作一回事。其实它们解决两个不同的问题。OAuth 2.0 解决授权问题(允许应用程序代表用户调用 API)。OIDC 解决身份认证问题(了解用户是谁)。
在本文中,你将理解 Authorization Code + PKCE 流程。然后你将构建两个 Spring Boot 3 应用程序:一个通过 OIDC 登录的客户端和一个验证 JWT 格式 access token 的资源服务器。文章末尾包含资源服务器的测试用例和常见错误列表。
2. OAuth 2.0 和 OpenID Connect 有什么区别
2.1. OAuth 2.0 中的各个角色
OAuth 2.0 定义了四个角色:
- Resource Owner: 用户,数据所有者(例如拥有订单的客户)。
- Client: 想要访问数据的应用程序(例如网站应用
shop-web)。 - Authorization Server: 用户登录和授权的地方,然后颁发令牌。例如 Keycloak、Okta、Auth0。
- Resource Server: 包含数据的 API(例如
order-api)。
OAuth 2.0 的结果是一个 access token。这个令牌是给资源服务器的,不是给客户端的。客户端只需要持有它并传递它。不应该通过读取令牌来推断用户是谁。
2.2. OIDC 补充了什么
OIDC 是 OAuth 2.0 之上的一个层。当客户端请求 openid 作用域时,授权服务器会额外返回一个 ID token。这总是一个 JWT,带有签名,包含身份认证信息:
{
"iss": "http://localhost:8180/realms/shop",
"sub": "customer-42",
"aud": "shop-web",
"exp": 1760353200,
"iat": 1760352900,
"nonce": "n-0S6_WzA2Mj",
"email": "lan@example.com",
"name": "Nguyễn Thị Lan"
}
重要的声明包括:
iss:谁颁发了令牌。sub:用户的唯一且稳定的标识符。aud:这个令牌是为哪个客户端准备的。nonce:防止重放。
你可以总结如下:
Access token -> 发送给 API(资源服务器) -> "被允许做什么"
ID token -> 客户端读取以登录 -> "用户是谁"
3. Authorization Code + PKCE 流程
对于网络应用、SPA 和移动应用,你应该使用 Authorization Code 流程,配合 PKCE(Proof Key for Code Exchange)。Implicit flow 和 Password grant 已被建议弃用。
1. 用户在 shop-web 点击"登录"
2. shop-web 生成随机的 code_verifier,计算 code_challenge = SHA256(code_verifier)
3. 重定向浏览器到:
/authorize?response_type=code&client_id=shop-web&scope=openid profile email
&redirect_uri=...&state=xyz&nonce=abc
&code_challenge=...&code_challenge_method=S256
4. 用户在 Keycloak 登录,Keycloak 重定向回:
/login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web(后端通道)调用 POST /token,传递 code + code_verifier + client_secret
6. Keycloak 返回:access_token、id_token、refresh_token
7. shop-web 验证 id_token(签名、iss、aud、exp、nonce)-> 创建会话
8. shop-web 使用头部 Authorization: Bearer <access_token> 调用 order-api
每个参数都能阻止特定类型的攻击:
state防止回调步骤中的 CSRF 攻击。nonce将 ID token 与正确的登录会话绑定。- PKCE 确保即使攻击者盗取了
code,也无法将其交换为令牌,因为缺少code_verifier。
好消息是 Spring Security 为你处理了上述几乎所有步骤。
4. 构建通过 OIDC 登录的客户端
4.1. 依赖和配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
假设 Keycloak 运行在端口 8180,有 realm shop 和客户端 shop-web:
spring:
security:
oauth2:
client:
registration:
shop:
provider: keycloak
client-id: shop-web
client-secret: ${SHOP_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid, profile, email, orders.read
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8180/realms/shop
你只需要声明 issuer-uri。Spring 会读取 /.well-known/openid-configuration 来自动获取 authorize、token、jwks 和 end_session 端点。openid 作用域是将 OAuth 2.0 流程转变为 OIDC 登录的关键。
4.2. 安全配置
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository registrations) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(
registrations, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated())
.oauth2Login(login -> login
.authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
.logout(logout -> logout.logoutSuccessHandler(logoutHandler));
return http.build();
}
}
这段配置做三件事:
oauth2Login启用第 3 节中的完整流程,包括重定向、回调、代码交换和 ID token 验证。withPkce()为机密客户端(拥有 secret 的客户端)添加code_challenge。公开客户端已经默认启用了 PKCE。OidcClientInitiatedLogoutSuccessHandler在 Keycloak 端点也进行注销(RP-initiated logout)。缺少它的话,用户再次点击“登录”会直接登录而无需输入密码。
4.3. 读取用户信息和调用 API
@RestController
public class AccountController {
private final RestClient orderApi = RestClient.create("http://localhost:8081");
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of(
"subject", user.getSubject(),
"email", Objects.requireNonNullElse(user.getEmail(), "(无)"),
"name", Objects.requireNonNullElse(user.getFullName(), "(无)"));
}
@GetMapping("/my-orders")
public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
return orderApi.get()
.uri("/api/orders")
.headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
.retrieve()
.body(String.class);
}
}
两个端点使用两种不同类型的令牌:
OidcUser由 ID token 构建而来(如果可用的话还有 UserInfo)。这是了解用户身份的正确方式。@RegisteredOAuth2AuthorizedClient获取 registrationshop的 access token。如果令牌已过期且有刷新令牌,Spring 会在返回前自动刷新。
登录后调用 /me 会返回:
{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}
注意: 如果值是
null,Map.of会抛出NullPointerException。
5. 构建验证 JWT 的资源服务器
5.1. 依赖和配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/shop
audiences: order-api
有了这个配置,Spring 会按照以下步骤检查每个请求:
- 从发行者的
jwks_uri获取公钥。 - 验证签名和
exp/nbf。 - 验证
iss与issuer-uri匹配。 - 验证
aud包含order-api。
缺少 audience 检查是一个非常常见的错误。这样的话,API 会接受发给同一 realm 中其他服务的令牌。
5.2. 基于作用域的权限分配
public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
return orderService.findByCustomer(jwt.getSubject());
}
}
默认情况下,Spring 将声明 scope(或 scp)转换为带有 SCOPE_ 前缀的 authority。因此作用域 orders.read 变成 SCOPE_orders.read。
为什么要禁用 CSRF?这个 API 是无状态的,只通过 Authorization 头接受令牌,不使用 cookie。因此禁用 CSRF 是合理的。
Controller 从令牌中获取 sub,而不是从查询字符串中接收 customerId。这样用户就无法查看他人的订单。
不同请求类型的结果:
没有令牌 -> 401 Unauthorized
令牌有效但缺少作用域 -> 403 Forbidden
令牌有效且有 orders.read -> 200 OK + 用户自己订单的列表
注意: Keycloak 将角色放在
realm_access.roles中,不在scope中。如果你想基于角色进行权限分配,需要声明一个单独的JwtAuthenticationConverter来将该声明映射到GrantedAuthority。
6. 测试资源服务器
你不需要真实的 Keycloak 来进行测试。spring-security-test 库有 jwt() post-processor 来模拟已认证的令牌。
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
OrderService orderService;
@MockitoBean
JwtDecoder jwtDecoder; // 避免启动 context 时调用真实的 issuer
@Test
void returnsOrdersOfCurrentCustomer() {
given(orderService.findByCustomer("customer-42"))
.willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));
assertThat(mvc.get().uri("/api/orders")
.with(jwt().jwt(j -> j.subject("customer-42"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
.hasStatusOk()
.bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
}
@Test
void rejectsTokenWithoutScope() {
assertThat(mvc.get().uri("/api/orders").with(jwt()))
.hasStatus(HttpStatus.FORBIDDEN);
}
@Test
void rejectsRequestWithoutToken() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
}
这三个测试覆盖了第 5.2 节的三个分支:
jwt()跳过解码步骤。它直接在SecurityContext中设置一个JwtAuthenticationToken,所以测试运行快速且独立。- 你必须显式传递
authorities(...)。jwt()默认只有SCOPE_read,所以第二个测试收到 403。 MockMvcTester(Spring Boot 3.4+)允许你在响应上直接使用 AssertJ 编写断言。
7. 常见错误和何时不应使用
使用 ID token 调用 API。 ID token 的 aud 是客户端,不是 API。正确配置的资源服务器会拒绝它。应始终发送 access token。
使用 access token 进行登录。 对于纯 OAuth 2.0,access token 可能是不透明的字符串,不包含任何关于用户的信息。如果客户端自己解析 access token JWT 来获取身份,它依赖于不保证的格式。应该使用 ID token 或 UserInfo 端点。
在 SPA 的 localStorage 中保存令牌。 一个 XSS 漏洞就足以暴露整个令牌。更安全的模式是 BFF(Backend for Frontend)。第 4 节中的 Spring 应用程序将令牌保存在服务器端,而浏览器只有 HttpOnly session cookie。
不验证 aud,或让 access token 活得太久。 JWT 在过期前无法撤销。应该保持 access token 的过期时间较短(5–15 分钟),并使用刷新令牌来延长。
issuer-uri 不匹配。 如果 Keycloak 返回的 iss 是 http://keycloak:8080/...(Docker 中的主机名)但你配置的是 localhost:8180,所有令牌都会以 invalid_token 错误被拒绝。iss 值必须完全匹配。
注意: 你不总是需要 OAuth 2.0。如果只有一个 monolith 应用,有表单登录,且没有第三方需要调用 API,Spring Security 的标准 session 更简单安全。对于没有用户的机器对机器通信,使用
client_credentialsgrant,而不是 Authorization Code。
8. 结论
OAuth 2.0 通过 access token 回答“这个应用程序被允许做什么”的问题。OIDC 通过 ID token 回答“用户是谁”的问题。应该使用 Authorization Code + PKCE 流程,配合 state 和 nonce。
通过 Spring Boot 3,你已经看到:
spring-boot-starter-oauth2-client配合oauth2Login处理客户端的整个 OIDC 登录部分。spring-boot-starter-oauth2-resource-server根据issuer-uri和audiences验证 JWT。spring-security-test允许你测试基于作用域的权限分配,而无需真实的授权服务器。
下一步你可以尝试使用 JwtAuthenticationConverter 映射 Keycloak 的角色,或为 SPA 将客户端分离为 BFF。