3. Spring Security OAuth2.0 服务端搭建:授权服务器配置、客户端注册与令牌端点

本章将基于 Spring Security OAuth2 Authorization Server 框架,从零搭建一个生产级授权服务器。核心任务包括:配置授权服务器元数据、注册受信客户端、暴露令牌端点与授权端点,并确保与后续异步导出服务的无缝集成。

版本说明:本课程使用 Spring Authorization Server 1.1.x(基于 Spring Security 6.x),已废弃旧的 @EnableAuthorizationServer 注解,全面采用组件化配置方式。

3.1 授权服务器核心组件与流程

授权服务器在 OAuth2.0 体系中扮演“认证与令牌发放中心”的角色。其核心流程包含三个关键步骤:客户端身份校验、用户授权确认、令牌签发与响应。

授权服务器核心流程 授权服务器核心流程(授权码模式) 客户端应用 授权端点 /oauth2/authorize 令牌端点 /oauth2/token 资源所有者(用户) 授权服务器核心 客户端注册表 + 令牌生成器 流程:客户端请求授权 → 用户认证并授权 → 授权码返回 → 客户端用授权码换取令牌

3.2 授权服务器配置(核心配置类)

使用 @Configuration + @EnableWebSecurity 注解声明配置类,通过 AuthorizationServerSecurityConfiguration 定制化授权服务器行为。以下为最小化配置模板:

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults()); // 启用 OpenID Connect 1.0
        http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        http.exceptionHandling(exceptions -> exceptions
            .authenticationEntryPoint(
                new BearerTokenAuthenticationEntryPoint()));
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        http.authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}
配置要点:
  • @Order(1) 的过滤器链优先处理 OAuth2 请求(路径匹配 /oauth2/**)。
  • @Order(2) 的默认链处理用户登录(表单登录),用于资源所有者认证。
  • BearerTokenAuthenticationEntryPoint 确保令牌失效时返回 401 而非重定向到登录页。

3.3 客户端注册(RegisteredClient)

客户端注册是授权服务器的“白名单”机制。每个调用方(如异步导出服务的前端或后端模块)必须预先注册,获得 client_id 和 client_secret。注册信息存储在 RegisteredClientRepository 中,支持内存、JDBC 或自定义实现。

字段 说明 示例值
clientId 客户端唯一标识 async-export-service
clientSecret 客户端密钥(需加密存储) {noop}secret 或 {bcrypt}$2a$10$...
authorizationGrantTypes 允许的授权类型 AUTHORIZATION_CODE, REFRESH_TOKEN
redirectUris 授权码回调地址 http://localhost:8080/login/oauth2/code/export
scopes 允许的权限范围 export:read, export:write
tokenSettings 令牌生命周期与格式 accessTokenTimeToLive: 30 分钟

以下为内存模式客户端注册示例:

@Bean
public RegisteredClientRepository registeredClientRepository() {
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("async-export-service")
        .clientSecret("{noop}export-secret-2024")
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("http://localhost:8080/login/oauth2/code/export")
        .scope("export:read")
        .scope("export:write")
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(30))
            .refreshTokenTimeToLive(Duration.ofDays(7))
            .build())
        .build();
    return new InMemoryRegisteredClientRepository(registeredClient);
}
生产安全提醒:切勿在代码中硬编码 clientSecret。应使用环境变量或密钥管理服务(如 Vault)注入,并采用 {bcrypt} 前缀加密存储。内存模式仅适用于开发/测试环境,生产环境请使用 JdbcRegisteredClientRepository。

3.4 令牌端点与授权端点

Spring Authorization Server 自动注册以下关键端点,无需手动编写 Controller:

端点路径 HTTP 方法 功能 请求参数
/oauth2/authorize GET 授权端点:用户确认授权后返回授权码 response_type=code, client_id, redirect_uri, scope, state
/oauth2/token POST 令牌端点:用授权码或刷新令牌换取访问令牌 grant_type=authorization_code, code, redirect_uri, client_id, client_secret
/oauth2/revoke POST 令牌撤销端点 token, token_type_hint
/.well-known/oauth-authorization-server GET 授权服务器元数据(OAuth 2.0 Discovery) 无

令牌端点默认返回 JSON 格式响应,包含 access_token、token_type、expires_in、refresh_token 和 scope 字段。以下为成功响应的示例:

{
  "access_token": "eyJraWQiOiIx...",
  "token_type": "Bearer",
  "expires_in": 1799,
  "refresh_token": "eyJraWQiOiIy...",
  "scope": "export:read export:write"
}

3.5 令牌生成与签名配置

授权服务器使用 JWKSource 生成 RSA 密钥对,对 JWT 令牌进行签名。客户端可通过 /.well-known/oauth-authorization-server 端点获取公钥,用于验证令牌合法性。

@Bean
public JWKSource<SecurityContext> jwkSource() {
    RSAKey rsaKey = new RSAKey.Builder(
        new RSAPublicKey(...))  // 从 KeyStore 或生成器加载
        .privateKey(new RSAPrivateKey(...))
        .keyID(UUID.randomUUID().toString())
        .build();
    JWKSet jwkSet = new JWKSet(rsaKey);
    return new ImmutableJWKSet<>(jwkSet);
}

@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
    return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
性能优化提示:生产环境中建议将 JWKSet 缓存到 Redis,避免每次令牌验证都重新加载密钥对。后续章节将详细讲解 Redis 缓存策略。

3.6 完整配置清单与验证步骤

搭建完成后,可通过以下步骤验证授权服务器是否正常工作:

  1. 启动服务:访问 http://localhost:9000/.well-known/oauth-authorization-server,确认返回 JSON 元数据。
  2. 获取授权码:浏览器访问 http://localhost:9000/oauth2/authorize?response_type=code&client_id=async-export-service&redirect_uri=http://localhost:8080/login/oauth2/code/export&scope=export:read,完成表单登录后重定向并携带 code 参数。
  3. 换取令牌:使用 curl 或 Postman 向 /oauth2/token 发送 POST 请求,携带授权码和客户端凭证,验证返回的 access_token 是否为 JWT 格式。
curl -X POST http://localhost:9000/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=授权码" \
  -d "redirect_uri=http://localhost:8080/login/oauth2/code/export" \
  -u "async-export-service:export-secret-2024"
客户端注册与令牌端点交互 客户端注册与令牌端点交互时序 客户端 客户端注册表 令牌端点 JWT 生成器 1. 注册客户端 2. 请求令牌 3. 签名 4. 返回 JWT 令牌 关键约束:令牌端点必须验证 client_id + client_secret 与注册表匹配,才签发令牌

3.7 常见配置陷阱与避坑指南

问题 原因 解决方案
令牌端点返回 401 客户端未通过 Basic Auth 或 POST 参数中缺少 client_secret 确认 ClientAuthenticationMethod.CLIENT_SECRET_BASIC 已配置,且请求头包含 Authorization: Basic base64(client_id:client_secret)
授权码回调后报 invalid_grant 授权码已过期(默认 5 分钟)或 redirect_uri 不匹配 确保回调 URI 与注册时完全一致(包括端口和路径),并在 5 分钟内完成令牌交换
JWT 令牌无法解码 资源服务器未配置正确的 jwk-set-uri 或公钥不匹配 在资源服务器配置 spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000/.well-known/oauth-authorization-server
刷新令牌失效 刷新令牌被重用或超过有效期 启用 refreshTokenReuse 为 false(默认),并设置合理的 refreshTokenTimeToLive