Spring Security OAuth2.1新版授权码模式实战与原理解析

Spring Security OAuth2.1 新版授权码模式实战与原理解析

大家好,今天我们来深入探讨 Spring Security OAuth2.1 中授权码模式的实际应用和底层原理。OAuth 2.0 授权码模式是目前最常用的授权方式之一,它通过引入授权码作为中间媒介,有效避免了客户端直接持有用户凭据的风险,提高了安全性。Spring Security OAuth2.1 在此基础上,进一步增强了对授权码模式的支持,提供了更加灵活和可配置的实现方案。

1. 授权码模式流程回顾

在深入代码之前,我们先回顾一下授权码模式的基本流程:

  1. 客户端请求授权: 用户通过客户端(例如 Web 应用)发起授权请求,客户端将用户重定向到授权服务器。
  2. 用户授权: 授权服务器验证用户身份,并向用户展示客户端请求的权限范围,请求用户授权。
  3. 授权服务器颁发授权码: 如果用户同意授权,授权服务器将颁发一个授权码给客户端。
  4. 客户端使用授权码请求访问令牌: 客户端使用授权码和客户端凭据(client ID 和 client secret)向授权服务器请求访问令牌(Access Token)和刷新令牌(Refresh Token)。
  5. 授权服务器验证并颁发令牌: 授权服务器验证授权码的有效性以及客户端身份,如果验证通过,则颁发访问令牌和刷新令牌。
  6. 客户端使用访问令牌访问受保护资源: 客户端使用访问令牌向资源服务器请求受保护的资源。

2. Spring Security OAuth2.1 核心组件

Spring Security OAuth2.1 提供了一系列核心组件来支持授权码模式:

  • AuthorizationServerConfigurer: 用于配置授权服务器的行为,包括客户端、授权码生成策略、令牌存储方式等。
  • ClientDetailsService: 用于管理客户端信息,包括 client ID、client secret、授权类型、重定向 URI 等。
  • AuthorizationCodeServices: 用于管理授权码的生成、存储和检索。
  • TokenGranter: 用于处理不同类型的授权请求,包括授权码模式。
  • TokenStore: 用于存储访问令牌和刷新令牌,可以选择内存存储、JDBC 存储、Redis 存储等。
  • AuthenticationManager: 用于认证用户身份。
  • UserDetailsService: 用于加载用户信息。
  • ResourceServerConfigurer: 用于配置资源服务器的行为,包括访问令牌的验证方式、权限控制等。
  • OAuth2AuthenticationProcessingFilter: 用于验证访问令牌,并根据令牌中的信息创建 Authentication 对象。

3. 搭建 Spring Security OAuth2.1 授权服务器

接下来,我们通过一个简单的示例来演示如何搭建一个基于 Spring Security OAuth2.1 的授权服务器。

3.1 项目依赖

首先,在 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>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-authorization-server</artifactId>
        <version>1.1.2</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3.2 配置授权服务器

创建一个配置类,用于配置授权服务器的行为。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;

import java.time.Duration;
import java.util.UUID;

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer();
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(Customizer.withDefaults());   // Enable OIDC endpoint: /connect/oidc1.0/

        http
                .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
                .authorizeHttpRequests(authorize ->
                        authorize.anyRequest().authenticated()
                )
                .csrf(csrf -> csrf.ignoringRequestMatchers(authorizationServerConfigurer.getEndpointsMatcher()))
                .apply(authorizationServerConfigurer);

        http
                .exceptionHandling(exceptions ->
                        exceptions.authenticationEntryPoint(
                                new LoginUrlAuthenticationEntryPoint("/login"))
                )
                .oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults()));

        return http.build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("messaging-client")
                .clientSecret("secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
                .redirectUri("http://127.0.0.1:8080/authorized")
                .scope(OidcScopes.OPENID)
                .scope("message.read")
                .scope("message.write")
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED).accessTokenTimeToLive(Duration.ofMinutes(5)).build())
                .build();

        // Save registered client in db as if in-memory
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        registeredClientRepository.save(registeredClient);

        return registeredClientRepository;
    }

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService users() {
        UserDetails user = User.withUsername("user")
                .password(passwordEncoder().encode("password"))
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

3.3 配置 Spring Security

创建一个配置类,用于配置 Spring Security 的行为。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class DefaultSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize ->
                        authorize.anyRequest().authenticated()
                )
                .formLogin(withDefaults());
        return http.build();
    }
}

3.4 资源服务器配置 (可选)

如果你还需要配置资源服务器,可以创建一个配置类,用于配置资源服务器的行为。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.RequestMatcher;

import static org.springframework.security.config.Customizer.withDefaults;
import static org.springframework.security.oauth2.client.CommonOAuth2Provider.GOOGLE;
import static org.springframework.security.web.util.matcher.AntPathRequestMatchers.antMatcher;

@Configuration
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain resourceSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(authorize ->
                        authorize.requestMatchers(antMatcher("/api/**")).hasAuthority("SCOPE_message.read")
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
        return http.build();
    }
}

4. 客户端配置

为了测试授权服务器,我们需要配置一个客户端。客户端需要注册到授权服务器,并提供以下信息:

  • Client ID: 客户端的唯一标识符。
  • Client Secret: 客户端的密钥,用于验证客户端身份。
  • Grant Types: 客户端支持的授权类型,例如 authorization_code
  • Redirect URI: 授权服务器在用户授权后,将用户重定向到的 URI。
  • Scopes: 客户端请求的权限范围,例如 readwrite

在上面的 AuthorizationServerConfig 中,我们已经配置了一个客户端:

5. 测试授权码模式

现在,我们可以使用一个 OAuth 2.0 客户端来测试授权码模式。这里我们使用 Postman 进行测试。

5.1 获取授权码

  1. 打开 Postman,创建一个新的请求。
  2. 选择 GET 方法,并输入授权服务器的授权端点 URI。例如:http://localhost:9000/oauth2/authorize
  3. Params 选项卡中,添加以下参数:
    • response_type: code
    • client_id: messaging-client
    • redirect_uri: http://127.0.0.1:8080/authorized
    • scope: message.read message.write
    • state: random_state (可选,用于防止 CSRF 攻击)
  4. 发送请求。
  5. 授权服务器会重定向到登录页面。输入用户名和密码(在上面的配置中,用户名为 user,密码为 password)。
  6. 登录成功后,授权服务器会重定向到 redirect_uri,并在 URI 中包含授权码。例如:http://127.0.0.1:8080/authorized?code=AUTHORIZATION_CODE&state=random_state

5.2 获取访问令牌

  1. 在 Postman 中,创建一个新的请求。
  2. 选择 POST 方法,并输入授权服务器的令牌端点 URI。例如:http://localhost:9000/oauth2/token
  3. Headers 选项卡中,添加以下头部:
    • Content-Type: application/x-www-form-urlencoded
    • Authorization: Basic <Base64 encoded client_id:client_secret> (例如:Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ=)
  4. Body 选项卡中,选择 x-www-form-urlencoded,并添加以下参数:
    • grant_type: authorization_code
    • code: 从上一步获取的授权码。
    • redirect_uri: http://127.0.0.1:8080/authorized
  5. 发送请求。
  6. 授权服务器会返回一个 JSON 响应,包含访问令牌(access_token)、刷新令牌(refresh_token)和令牌类型(token_type)。

5.3 访问受保护资源

  1. 在 Postman 中,创建一个新的请求。
  2. 选择 GET 方法,并输入资源服务器的受保护资源 URI。例如:http://localhost:8080/api/messages
  3. Headers 选项卡中,添加以下头部:
    • Authorization: Bearer <access_token> (例如:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...)
  4. 发送请求。
  5. 资源服务器会验证访问令牌,并返回受保护的资源。

6. 授权码模式原理解析

现在,我们深入了解一下 Spring Security OAuth2.1 中授权码模式的实现原理。

6.1 授权请求处理

当客户端发起授权请求时,OAuth2AuthorizationEndpoint 会处理该请求。它会验证请求参数,例如 response_typeclient_idredirect_uriscope 等。如果验证通过,OAuth2AuthorizationEndpoint 会将用户重定向到登录页面,请求用户授权。

6.2 授权码生成与存储

当用户授权后,OAuth2AuthorizationEndpoint 会生成一个授权码,并将其存储到 AuthorizationCodeServices 中。AuthorizationCodeServices 接口定义了授权码的生成、存储和检索方法。Spring Security OAuth2.1 提供了多种 AuthorizationCodeServices 实现,例如 InMemoryAuthorizationCodeServicesJdbcAuthorizationCodeServices 等。

6.3 访问令牌请求处理

当客户端使用授权码请求访问令牌时,OAuth2TokenEndpoint 会处理该请求。它会验证授权码的有效性以及客户端身份。如果验证通过,OAuth2TokenEndpoint 会生成一个访问令牌和一个刷新令牌,并将它们存储到 TokenStore 中。TokenStore 接口定义了访问令牌和刷新令牌的存储和检索方法。Spring Security OAuth2.1 提供了多种 TokenStore 实现,例如 InMemoryTokenStoreJdbcTokenStoreJwtTokenStore 等。

6.4 访问令牌验证

当客户端使用访问令牌访问受保护资源时,OAuth2AuthenticationProcessingFilter 会验证访问令牌的有效性。如果验证通过,OAuth2AuthenticationProcessingFilter 会根据令牌中的信息创建一个 Authentication 对象,并将其存储到 SecurityContextHolder 中。

7. 代码示例:自定义授权码生成策略

Spring Security OAuth2.1 允许我们自定义授权码的生成策略。我们可以通过实现 OAuth2AuthorizationCodeGenerator 接口来达到这个目的。

import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthorizationCode;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCodeGenerator;

import java.time.Instant;
import java.util.UUID;

public class CustomAuthorizationCodeGenerator implements OAuth2AuthorizationCodeGenerator {

    @Override
    public OAuth2AuthorizationCode generate(OAuth2Authorization authorization) {
        String code = UUID.randomUUID().toString().replace("-", ""); // Remove hyphens for simplicity
        return new OAuth2AuthorizationCode(code, Instant.now(), Instant.now().plusSeconds(300)); // Expires in 5 minutes
    }
}

然后,我们需要在配置类中注册这个自定义的授权码生成器:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCodeGenerator;

@Configuration
public class AuthorizationServerConfig {

    @Bean
    public OAuth2AuthorizationCodeGenerator authorizationCodeGenerator() {
        return new CustomAuthorizationCodeGenerator();
    }

    // ... other configurations
}

8. 代码示例:自定义 TokenStore 使用 Redis

以下是如何配置 RedisTokenStore 的示例:

首先,确保你的项目中包含了 Redis 的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后,配置 TokenStore 使用 Redis:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.jdbc.core.JdbcTemplate;

@Configuration
public class AuthorizationServerConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new JdkSerializationRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new JdkSerializationRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    // ... other configurations
}

9. 安全注意事项

  • Client Secret 的保护: 务必安全地存储和管理 Client Secret,避免泄露。
  • Redirect URI 的验证: 严格验证 Redirect URI,防止攻击者利用重定向漏洞。
  • 授权码的有效期: 设置合理的授权码有效期,避免授权码被恶意使用。
  • 访问令牌的有效期: 设置合理的访问令牌有效期,降低令牌泄露带来的风险。
  • 使用 HTTPS: 确保所有通信都使用 HTTPS,防止数据被窃听。
  • CSRF 防护: 在授权请求中添加 state 参数,用于防止 CSRF 攻击。
  • PKCE 的使用: Proof Key for Code Exchange (PKCE) 可以增强授权码流程的安全性,尤其是在公共客户端中。

10. 常见问题与解决方案

问题 解决方案
授权服务器无法启动 检查依赖是否正确引入,配置是否正确。
获取授权码失败 检查 Client ID、Redirect URI、Scope 是否正确,用户是否已经登录并授权。
获取访问令牌失败 检查授权码是否有效,Client Secret 是否正确,Redirect URI 是否与授权请求中的 Redirect URI 一致。
访问受保护资源失败 检查访问令牌是否有效,是否具有访问该资源的权限。
刷新令牌失败 检查刷新令牌是否有效,Client ID 和 Client Secret 是否正确。
invalid_client 错误 确认 client_idclient_secret 是否正确,以及客户端的认证方式是否配置正确。 例如,如果配置了 CLIENT_SECRET_BASIC,则需要使用 Basic 认证方式。
unauthorized_client 错误 检查客户端是否被授权使用请求的授权类型。 例如,如果客户端未被配置为使用 authorization_code 授权类型,则会收到此错误。
invalid_grant 错误 通常表示授权码或刷新令牌无效。 请确保授权码未被使用过,且刷新令牌未过期或被撤销。
redirect_uri_mismatch 错误 redirect_uri 必须与在客户端注册时配置的完全一致。 请检查两者是否完全匹配。
跨域问题(CORS) 配置授权服务器和资源服务器的 CORS 策略,允许来自客户端域的请求。

总结:安全授权,灵活配置

Spring Security OAuth2.1 提供了强大的授权码模式支持,通过合理的配置和安全措施,我们可以构建安全可靠的授权系统。 它提供了灵活的配置选项,允许我们自定义授权码生成策略和令牌存储方式,满足各种业务需求。同时,需要关注客户端 secret 的保护、redirect URI 的验证以及令牌的有效期等安全问题,以确保系统的安全性。

授权码模式:核心与优化

授权码模式的核心在于通过授权码这一中间层,隔离了客户端与用户凭证,提升了安全性。Spring Security OAuth2.1 提供了灵活的配置选项和扩展点,可以根据实际需求进行定制。掌握其原理和实践,能更好地构建安全可靠的 OAuth 2.0 授权系统。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注