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 获取 registration shop 的 access token。如果令牌已过期且有刷新令牌,Spring 会在返回前自动刷新。

登录后调用 /me 会返回:

{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}

注意: 如果值是 null,Map.of 会抛出 NullPointerException。email 声明只有在你请求了 email 作用域且用户有电子邮件时才会出现,所以要始终处理声明缺失的情况。

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_credentials grant,而不是 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。

oauth2openid-connectspring-securityjwtweb-security