Skip to content

不透明令牌支持

概述

JWT 是一种以广泛接受的 JSON 格式安全传输敏感信息的方法。包含的信息可能是关于用户的,也可能是关于令牌本身的,例如它的到期时间和发行者。 但是将令牌信息打包放入令牌本身也有其不足之处。为了包含所有必要的声明以及保护这些声明所需的签名结构,令牌尺寸会变得非常大。而且, 如果受保护资源完全依赖令牌本身所包含的信息,则一旦将有效的令牌生成并发布,想要撤回会非常困难。

OAuth2 令牌内省协议定义了一种机制,让受保护资源能够主动向授权服务器查询令牌状态。本文我们不在使用JWT结构化令牌,而是使用不透明令牌做为访问令牌。 顾名思义,不透明令牌就其携带的信息而言是不透明的。令牌只是一个标识符,指向存储在授权服务器上的信息;它通过授权服务器的内省得到验证。

授权服务器

本节中,我们将学习使用Spring Authorization Server设置 OAuth 2.0 授权服务器,并且我们将使用不透明令牌,和以往文章中授权服务器生成JWT令牌配置相比,配置过程中仅有微小的变动。

AuthorizationServerConfig.java

java
/*
 *    Copyright [yyyy] [name of copyright owner]
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */

package com.pigcloud.pigx.auth.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.pigcloud.pigx.auth.authentication.DeviceClientAuthenticationConverter;
import com.pigcloud.pigx.auth.authentication.DeviceClientAuthenticationProvider;
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.http.MediaType;
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.factory.PasswordEncoderFactories;
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.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
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.AuthorizationServerSettings;
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 org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.UUID;

/**
 * 授权服务器配置
 *
 * @author heyuq
 */
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

    /**
     * 自定义同意授权页面。
     */
    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";

    /**
     * 授权服务器端点配置
     *
     * @param http spring security核心配置类
     * @return 过滤器链
     * @throws Exception 抛出
     */
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(
            HttpSecurity http, RegisteredClientRepository registeredClientRepository,
            AuthorizationServerSettings authorizationServerSettings) throws Exception {

        // 应用OAuth2授权服务器的默认安全配置。
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
                new DeviceClientAuthenticationConverter(
                        authorizationServerSettings.getDeviceAuthorizationEndpoint());
        DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
                new DeviceClientAuthenticationProvider(registeredClientRepository);

        // 配置OAuth2授权服务器的安全过滤链,启用OpenID Connect 1.0。
        // OpenID Connect是OAuth 2.0的一个扩展,用于添加用户身份验证。
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                // 设置设备码用户验证url(自定义用户验证页)
                .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
                        deviceAuthorizationEndpoint.verificationUri("/activate")
                )
                // 设置验证设备码用户确认页面
                .deviceVerificationEndpoint(deviceVerificationEndpoint ->
                        deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
                )
                .clientAuthentication(clientAuthentication ->
                        // 客户端认证添加设备码的converter和provider
                        clientAuthentication
                                .authenticationConverter(deviceClientAuthenticationConverter)
                                .authenticationProvider(deviceClientAuthenticationProvider)
                )
                // 设置自定义用户确认授权页
                .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint
                        .consentPage(CUSTOM_CONSENT_PAGE_URI)
                )
                .oidc(Customizer.withDefaults());

        // @formatter:off
        http
            // 配置异常处理,当从授权端点未认证时,重定向到登录页面。
            // 这里定义了一个登录URL认证入口点,并指定它只适用于TEXT_HTML媒体类型。
            .exceptionHandling((exceptions) -> exceptions
                .defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                )
            )
            // 配置OAuth2资源服务器,接受用户信息和/或客户端注册的访问令牌。
            // 这里使用JWT(JSON Web Tokens)作为访问令牌的格式。
            .oauth2ResourceServer((resourceServer) -> resourceServer
                // .jwt(Customizer.withDefaults())
                .opaqueToken(Customizer.withDefaults())
            );
        // @formatter:on

        // 构建并返回配置好的SecurityFilterChain对象。
        return http.build();
    }

    /**
     * 默认安全过滤器配置
     *
     * @param http spring security核心配置类
     * @return 过滤器链
     * @throws Exception 抛出
     */
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {

        // @formatter:off
        http
            // 配置默认的HTTP请求授权规则,要求所有请求都必须经过身份验证。
            .authorizeHttpRequests((authorize) -> authorize
                // 放行静态资源
                .requestMatchers("favicon.svg", "/assets/**", "/webjars/**", "/error").permitAll()
                .anyRequest().authenticated()
            )
            // 配置表单登录,使用默认设置。
            // 这处理从授权服务器过滤链重定向到登录页面的情况。
            // .formLogin(Customizer.withDefaults())
            .formLogin(formLogin ->
                formLogin
                    .loginPage("/login")
                    .permitAll()
            );
        // @formatter:on

        return http.build();
    }

    /**
     * 先暂时配置一个基于内存的用户,框架在用户认证时会默认调用
     * {@link UserDetailsService#loadUserByUsername(String)} 方法根据
     * 账号查询用户信息,一般是重写该方法实现自己的逻辑
     *
     * @return UserDetailsService
     */
    @Bean
    public UserDetailsService userDetailsService() {

        // 创建一个具有默认密码编码器的用户详情对象。
        // 这里定义了一个用户名为"admin",密码为"password",角色为"ADMIN"的用户。
        UserDetails userDetails = User.withUsername("admin")
                .password("{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG")
                .roles("ADMIN")
                .authorities("message_read", "message_write")
                .build();

        // 返回一个基于内存的用户详情服务,它仅包含上面定义的用户。
        return new InMemoryUserDetailsManager(userDetails);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    /**
     * 配置客户端Repository
     *
     * @param jdbcTemplate    db 数据源信息
     * @return 基于数据库的repository
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        // 创建一个RegisteredClient对象,并为其分配一个唯一的ID,该ID是基于UUID生成的。
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                // 设置该客户端的ID为"messaging-client"。
                .clientId("messaging-client")
                // 设置客户端的秘密为"{noop}secret",这里的"{noop}"表示不对秘密进行加密处理。
                .clientSecret("{noop}secret")
                // 设置客户端认证方法为基于客户端秘密的基本认证。
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                // 为该客户端配置授权类型,首先配置为授权码模式。
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                // 添加刷新令牌授权类型,允许客户端使用刷新令牌来获取新的访问令牌。
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                // 添加客户端凭证授权类型,允许客户端使用其凭证直接获取令牌。
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                // 设置授权码流程的重定向URI,当授权流程完成后,用户将被重定向到这个URI。
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
                // 设置另一个重定向URI,可能用于其他授权流程。
                .redirectUri("http://127.0.0.1:8080/authorized")
                // 设置一个仅用于测试的重定向URI,用于接收授权码。
                .redirectUri("https://www.pigx.cn")
                // 设置用户注销后的重定向URI。
                .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
                // 添加OpenID Connect的范围,允许客户端访问用户的身份信息。
                .scope(OidcScopes.OPENID)
                // 添加Profile范围,允许客户端访问用户的个人资料信息。
                .scope(OidcScopes.PROFILE)
                // 添加自定义范围"message.read",允许客户端读取消息。
                .scope("message.read")
                // 添加自定义范围"message.write",允许客户端写入消息。
                .scope("message.write")
                // 设置客户端设置,要求每次都需要用户授权同意。
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                // 配置令牌设置,设置访问令牌的有效时间为30分钟。
                .tokenSettings(TokenSettings.builder()
                        // .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        .accessTokenFormat(OAuth2TokenFormat.REFERENCE)
                        .accessTokenTimeToLive(Duration.ofMinutes(30))
                        .build())
                // 构建并返回配置好的RegisteredClient对象。
                .build();

        // 创建另一个RegisteredClient对象,名为deviceClient,用于设备授权
        RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("device-messaging-client")
                // 公共客户端
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                // 设备码授权
                .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                // 自定scope
                .scope("message.read")
                .scope("message.write")
                .build();

        // 创建一个JdbcRegisteredClientRepository实例,用于将已注册的客户端保存到数据库中
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);

        // 初始化客户端
        if (registeredClientRepository.findByClientId(registeredClient.getClientId()) == null) {
            registeredClientRepository.save(registeredClient);
        }

        // 创建另一个RegisteredClient对象,名为deviceClient,用于设备授权
        if (registeredClientRepository.findByClientId(deviceClient.getClientId()) == null) {
            registeredClientRepository.save(deviceClient);
        }

        return registeredClientRepository;
    }

    /**
     * 配置基于db的oauth2的授权管理服务
     *
     * @param jdbcTemplate               db数据源信息
     * @param registeredClientRepository 上边注入的客户端repository
     * @return JdbcOAuth2AuthorizationService
     */
    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        // 基于db的oauth2认证服务,还有一个基于内存的服务InMemoryOAuth2AuthorizationService
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    /**
     * 存储新授权同意和查询现有授权同意的核心组件。它主要由实现 OAuth2 授权请求流(例如 authorization_code 授权)的组件使用
     *
     * @param jdbcTemplate               db数据源信息
     * @param registeredClientRepository 客户端repository
     * @return JdbcOAuth2AuthorizationConsentService
     */
    @Bean
    public JdbcOAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
                                                                             RegisteredClientRepository registeredClientRepository) {
        // Will be used by the ConsentController
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }

    /**
     * 配置jwk源,使用非对称加密,公开用于检索匹配指定选择器的JWK的方法
     *
     * @return JWKSource
     */
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        // 生成RSA密钥对。
        KeyPair keyPair = generateRsaKey();

        // 从密钥对中获取公钥。
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();

        // 从密钥对中获取私钥。
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

        // 使用公钥和私钥创建一个RSAKey对象,并为其分配一个随机的UUID作为keyID。
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();

        // 创建一个包含这个RSAKey的JWKSet对象。
        JWKSet jwkSet = new JWKSet(rsaKey);

        // 返回一个不可变的JWKSet对象,这通常是为了保证安全而设计的。
        return new ImmutableJWKSet<>(jwkSet);
    }

    /**
     * 私有方法,用于生成RSA密钥对。
     *
     * @return KeyPair
     */
    private static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            // 实例化一个RSA密钥对生成器。
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");

            // 初始化密钥对生成器,指定密钥长度为2048位。
            keyPairGenerator.initialize(2048);

            // 生成RSA密钥对。
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            // 如果发生异常,则抛出IllegalStateException。
            throw new IllegalStateException(ex);
        }
        // 返回生成的密钥对。
        return keyPair;
    }

    /**
     * 用于解码签名访问令牌的 JwtDecoder 实例。
     *
     * @param jwkSource jwk源
     * @return JwtDecoder
     */
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        // 使用OAuth2AuthorizationServerConfiguration类中的静态方法jwtDecoder,
        // 根据提供的JWKSource对象创建JwtDecoder。
        // JwtDecoder是用于解码JWT的组件。
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    /**
     * 配置OAuth 2.0授权服务器。
     *
     * @return AuthorizationServerSettings
     */
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        // 创建一个AuthorizationServerSettings对象,并使用默认设置。
        // 这个对象通常用于配置授权服务器的各种设置。
        return AuthorizationServerSettings.builder().build();
    }

}

资源服务器

application.yml

在这里的application.yml中,我们需要添加一个与我们的授权服务器的内省端点相对应的内省 uri 。这是验证不透明令牌的方式

yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000
yaml
spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          client-id: messaging-client
          client-secret: secret
          introspection-uri: http://localhost:9000/oauth2/introspect

ResourceServerConfig.java

java
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers(HttpMethod.GET, "/message/**").hasAuthority("SCOPE_message.read")
                .requestMatchers(HttpMethod.POST, "/message/**").hasAuthority("SCOPE_message.write")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer((oauth2) -> oauth2
                .jwt(Customizer.withDefaults())
            );
        // @formatter:on
        return http.build();
    }

}
java
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers(HttpMethod.GET, "/message/**").hasAuthority("SCOPE_message.read")
                .requestMatchers(HttpMethod.POST, "/message/**").hasAuthority("SCOPE_message.write")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer((oauth2) -> oauth2
                .opaqueToken(Customizer.withDefaults())
            );
        // @formatter:on
        return http.build();
    }

}

MessagesController.java

java
@GetMapping("/")
public String index(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
    return String.format("Hello, %s!", (String) principal.getAttribute("sub"));
}

Oauth2 认证流程

用户请求 resouece-server 过程

  • user
    • BearerTokenAuthenticationFilter: 解析 Authentication: Bearer {token} 中的token
    • 交给 OpaqueTokenAuthenticationProvider
      • OpaqueTokenAuthenticationProvider 委托 OpaqueTokenIntrospector 的 introspect 去校验 token
      • OpaqueTokenIntrospector
        • SpringOpaqueTokenIntrospector: OpaqueTokenIntrospector的实现
          • 添加 BasicAuthenticationInterceptor 拦截器
            • 在认证之前,需要对该resource-server 的 client_id 和client_secret 进行认证
            • 在Header 上添加 Authentication: Basic {Base64.encode(client_id:client_secret)}
            • 在authentication-server 会被 OAuth2ClientAuthenticationFilter 拦截
          • RestTemplate
            • 通过 RestTemplate 去请求 authentication-server 的 /oauth2/introspect
            • 在authentication-server 会被 OAuth2TokenIntrospectionEndpointFilter 拦截

authentication-server 认证过程:

  • OAuth2ClientAuthenticationFilter
    • OAuth2ClientAuthenticationFilter 的 ClientSecretBasicAuthenticationConverter 会检索出 Authentication 中 Basic 的token
    • 委托 ClientSecretAuthenticationProvider 对 client_id 和 client_secret 进行认证
    • 认证成功,交给下一个Filter
  • OAuth2TokenIntrospectionEndpointFilter
    • 拦截·/oauth2/introspect 请求
    • 委托 OAuth2TokenIntrospectionAuthenticationProvider 认证
      • OAuth2TokenIntrospectionAuthenticationProvider
        • 通过 OAuth2AuthorizationService 根据 token 到数据库查询 是否有 认证过的 OAuth2Authorization
        • 返回 OAuth2TokenIntrospectionAuthenticationToken

结论

在本文中,我们学习了如何配置基于 Spring Security 的资源服务器应用程序来验证不透明令牌。在使用令牌内省会导致 OAuth 2.0 系统内的网络流量增加。为了解决 这个问题,我们可以允许受保护资源缓存给定令牌的内省请求结果。建议设置短于令牌生命周期的缓存有效期,以便降低令牌被撤回但缓存还有效的可能性。

基于 MIT 许可发布