跳转至

实战:Spring Authorization Server

版本迁移重要说明

Spring Authorization Server 1.5.x 是最后一代独立版本。 从 Spring Security 7.0 起,Spring Authorization Server 的功能已合并进 Spring Security 主项目,不再作为单独的依赖存在。

  • 1.5.x 对应 Spring Boot 3.5.x,artifactId 为 spring-security-oauth2-authorization-server
  • Spring Security 7.0 起,直接引入 spring-boot-starter-oauth2-authorization-server
  • 本文以 1.5.x 为主,文末提供 7.0 迁移要点

配置方式说明

Spring Authorization Server 提供两种配置方式:

  • 方式一(推荐):手动创建授权服务器 Filter Chain,灵活可扩展,本文采用此方式
  • 方式二(快速体验):使用 @Import(OAuth2AuthorizationServerConfiguration.class) 自动配置,仅适合无需自定义的极简场景,但不能与手动 Filter Chain 同时使用

理论回顾

在使用 Spring Authorization Server 之前,先回顾「核心概念」中的四个角色如何映射到 Spring 的组件:

核心概念 Spring 组件 说明
Client RegisteredClient 定义客户端的 ID、Secret、授权类型、Scope、Redirect URI 等
Authorization Server AuthorizationServerSettings 配置授权端点、令牌端点、JWKS 端点的 URL
签名能力 JWKSource 提供 JWT 签名所需的密钥对
Resource Owner UserDetailsService 提供用户认证信息(用户名、密码、角色)

这三个 Bean 加上 SecurityFilterChain 就构成了一个最小可用的授权服务器。下面通过"快速入门"演示如何配置。

🚀 快速入门

Maven 依赖

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- Spring Authorization Server 1.5.x(最后独立版本) -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-authorization-server</artifactId>
    </dependency>
</dependencies>

最小化配置

AuthorizationServerConfig.java
@Configuration
public class AuthorizationServerConfig {

    // (1)!
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("my-client")
            .clientSecret("{noop}my-secret") // (2)!
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .redirectUri("http://localhost:8080/login/oauth2/code/my-client")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("read")
            .clientSettings(ClientSettings.builder()
                .requireProofKey(true) // (3)!
                .build())
            .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(30))
                .refreshTokenTimeToLive(Duration.ofDays(7))
                .reuseRefreshTokens(false) // (4)!
                .build())
            .build();
        return new InMemoryRegisteredClientRepository(client);
    }

    // (5)!
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    private static RSAKey generateRsa() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();
    }

    private static KeyPair generateRsaKey() {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(2048);
            return generator.generateKeyPair();
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException(ex);
        }
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("http://localhost:9000") // (6)!
            .build();
    }
}
  1. 注册客户端仓库(生产环境应使用 JdbcRegisteredClientRepository
  2. {noop} 表示明文密码,生产环境必须使用 BCrypt 编码
  3. 强制要求 PKCE,适用于公开客户端
  4. 禁用 Refresh Token 复用,启用 Rotation 机制
  5. JWK 密钥源,生产环境应从文件或 KMS 加载固定密钥
  6. Issuer 必须与客户端配置的 issuer-uri 一致

配置 Spring Security 以开放授权服务器端点

SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1) // (1)!
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
            OAuth2AuthorizationServerConfigurer.authorizationServer();

        http
            .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
            .with(authorizationServerConfigurer, authorizationServer ->
                authorizationServer
                    .oidc(withDefaults()) // (2)!
            )
            .exceptionHandling(exceptions ->
                exceptions.defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                )
            );

        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        http
            .authorizeHttpRequests(authorize ->
                authorize.anyRequest().authenticated()
            )
            .formLogin(withDefaults()); // (3)!

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        // 生产环境替换为数据库实现
        // ⚠️ withDefaultPasswordEncoder() 在 Spring Security 6.x 已标注 @Deprecated
        // 仅供快速演示,生产环境请使用 BCryptPasswordEncoder 或数据库存储
        UserDetails user = User.builder()
            .username("admin")
            .password("{bcrypt}" + new BCryptPasswordEncoder().encode("password"))
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(user);
    }
}
  1. 授权服务器 Security 链优先级必须高于默认链
  2. 启用 OpenID Connect 1.0 支持
  3. 默认使用表单登录,生产环境可自定义登录页

🎨 自定义授权页面

默认情况下 Spring Authorization Server 提供一个简单的授权确认页面。通过 authorizationEndpoint() 可以替换为自定义页面:

AuthorizationServerConfig.java(授权页面自定义片段)
1
2
3
4
5
6
7
8
9
http
    .with(authorizationServerConfigurer, authorizationServer ->
        authorizationServer
            .authorizationEndpoint(authorizationEndpoint ->
                authorizationEndpoint
                    .consentPage("/oauth2/consent") // (1)!
            )
            .oidc(withDefaults())
    );
  1. 指定自定义授权确认页面的路径,需在对应 Controller 中处理该路由并返回 Thymeleaf/HTML 页面
ConsentController.java
@Controller
public class ConsentController {

    // 展示授权确认页面(列出请求的 scope 供用户勾选)
    @GetMapping("/oauth2/consent")
    public String consent(Principal principal, Model model,
            @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
            @RequestParam(required = false, value = OAuth2ParameterNames.SCOPE) String scope, // (1)!
            @RequestParam(OAuth2ParameterNames.STATE) String state) {
        model.addAttribute("clientId", clientId);
        // scope 可能为 null(客户端未请求任何 scope 时),需做空值防护
        Set<String> scopes = scope != null
            ? new HashSet<>(Arrays.asList(scope.split(" ")))
            : Collections.emptySet();
        model.addAttribute("scopes", scopes);
        model.addAttribute("state", state);
        model.addAttribute("principalName", principal.getName());
        return "consent"; // 对应 templates/consent.html
    }
}
  1. required = false 防止 scope 参数为 null 时抛出 MissingServletRequestParameterException

🏷️ 自定义令牌 Claims

使用 OAuth2TokenCustomizer 向 Access Token 或 ID Token 中添加自定义声明:

TokenCustomizerConfig.java
@Configuration
public class TokenCustomizerConfig {

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
        return context -> {
            if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
                // 向 Access Token 添加用户角色
                Authentication principal = context.getPrincipal();
                Set<String> authorities = principal.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toSet());
                context.getClaims().claim("roles", authorities);
            }

            if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
                // 向 ID Token 添加额外用户信息
                context.getClaims().claim("custom_claim", "custom_value");
            }
        };
    }
}

⚙️ 配置 application.yml

application.yml
1
2
3
4
5
6
7
8
9
server:
  port: 9000

spring:
  security:
    user:
      # 开发调试用,生产环境移除
      name: admin
      password: password

UserDetailsService Bean 的优先级

若已在代码中定义了 UserDetailsService Bean(如上方的 InMemoryUserDetailsManager),Spring Boot Auto-configuration 会忽略 spring.security.user 属性配置。application.yml 中的 spring.security.user 仅在没有 UserDetailsService Bean 时生效,用于快速测试。

🔄 Spring Security 7.0 迁移要点

从 Spring Authorization Server 1.5.x 迁移到 Spring Security 7.0 的主要变化:

变化项 1.5.x 7.0+
依赖 artifactId spring-security-oauth2-authorization-server spring-boot-starter-oauth2-authorization-server
OAuth2AuthorizationServerConfigurer 独立类 集成进 Spring Security DSL
@Import(OAuth2AuthorizationServerConfiguration.class) 极简场景可用(不推荐生产使用) 通过 Security Filter Chain 配置

关注官方迁移指南

Spring Security 7.0 发布后,请参考官方迁移文档(本笔记站持续更新中)以及 Spring Security 官方 Release Notes


上一篇: 威胁模型与攻击面 下一篇: 实战:客户端与资源服务器