Spring Security OAuth2 內(nèi)省協(xié)議與 JWT 結(jié)合使用指南

Spring Security OAuth2 內(nèi)省協(xié)議與 JWT 結(jié)合使用指南

概述

我們已經(jīng)熟悉兩種用于授權(quán)服務(wù)器和受保護(hù)資源之間傳遞信息的方法:JWT(JSON Web Token)和令牌內(nèi)省。
但實(shí)際上,將它們結(jié)合起來使用也可以得到很好的效果。尤其在受保護(hù)資源要接受來自多個(gè)授權(quán)服務(wù)器的令牌的情況下特別有用。受保護(hù)資源可以先解析 JWT,弄清楚
令牌頒發(fā)自哪一個(gè)授權(quán)服務(wù)器,然后向?qū)?yīng)的授權(quán)服務(wù)器發(fā)送內(nèi)省請(qǐng)求以獲取詳細(xì)信息。

這篇文章將介紹如何實(shí)現(xiàn)Spring Security 5設(shè)置資源服務(wù)器實(shí)現(xiàn)內(nèi)省協(xié)議與JWT的結(jié)合使用,讓我們開始實(shí)踐吧!

授權(quán)服務(wù)器

在本節(jié)中我們將使用 Spring Authorization Server 搭建授權(quán)服務(wù)器,訪問令牌格式為
JWT(JSON Web Token)。

Maven依賴

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>2.6.7</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-authorization-server</artifactId>
            <version>0.3.1</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.6.7</version>
        </dependency>

配置

首先我們通過application.yml指定服務(wù)端口:

server:
  port: 8080

接下來我們創(chuàng)建AuthorizationServerConfig配置類,在此類中我們將創(chuàng)建授權(quán)服務(wù)所需Bean。下面我們將為授權(quán)服務(wù)器創(chuàng)建一個(gè)OAuth2客戶端,RegisteredClient
包含客戶端信息,它將由RegisteredClientRepository管理。

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("relive-client")
                .clientSecret("{noop}relive-client")
                .clientAuthenticationMethods(s -> {
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
                })
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
                .scope("message.read")
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(true)
                        .requireProofKey(false)
                        .build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                        .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                        .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                        .reuseRefreshTokens(false)
                        .build())
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

上述由RegisteredClient定義的OAuth2 客戶端參數(shù)信息說明如下:

  • id: 唯一標(biāo)識(shí)
  • clientId: 客戶端標(biāo)識(shí)符
  • clientSecret: 客戶端秘密
  • clientAuthenticationMethods: 客戶端可能使用的身份驗(yàn)證方法。支持的值為client_secret_basic、client_secret_postprivate_key_jwt、client_secret_jwtnone
  • authorizationGrantTypes: 客戶端可以使用的授權(quán)類型。支持的值為authorization_code、implicit、password、client_credentialsrefresh_tokenurn:ietf:params:oauth:grant-type:jwt-bearer
  • redirectUris: 客戶端已注冊(cè)重定向 URI
  • scopes: 允許客戶端請(qǐng)求的范圍
  • clientSettings: 客戶端的自定義設(shè)置
    • requireAuthorizationConsent: 是否需要授權(quán)統(tǒng)同意
    • requireProofKey: 當(dāng)參數(shù)為true時(shí),該客戶端支持PCKE
  • tokenSettings: OAuth2 令牌的自定義設(shè)置
    • accessTokenFormat: 訪問令牌格式,支持OAuth2TokenFormat.SELF_CONTAINED(自包含的令牌使用受保護(hù)的、有時(shí)間限制的數(shù)據(jù)結(jié)構(gòu),例如JWT);OAuth2TokenFormat.REFERENCE(不透明令牌)
    • accessTokenTimeToLive: access_token有效期
    • refreshTokenTimeToLive: refresh_token有效期
    • reuseRefreshTokens: 是否重用刷新令牌。當(dāng)參數(shù)為true時(shí),刷新令牌后不會(huì)重新生成新的refreshToken

ProviderSettings包含OAuth2授權(quán)服務(wù)器的配置設(shè)置。它指定了協(xié)議端點(diǎn)的URI以及發(fā)行人標(biāo)識(shí)。此處issuer在下文將由受保護(hù)資源解析用于區(qū)分授權(quán)服務(wù)器。

    @Bean
    public ProviderSettings providerSettings() {
        return ProviderSettings.builder()
                .issuer("http://127.0.0.1:8080")
                .build();
    }

我們將通過OAuth2AuthorizationServerConfiguration將OAuth2默認(rèn)安全配置應(yīng)用于HttpSecurity,同時(shí)對(duì)于未認(rèn)證請(qǐng)求重定向到登錄頁(yè)面。

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        return http.exceptionHandling(exceptions -> exceptions.
                authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))).build();
    }

授權(quán)服務(wù)器需要其用于JWT令牌的簽名密鑰,讓我們生成一個(gè)的 RSA 密鑰:

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

最后我們將定義Spring Security安全配置類,定義Form表單認(rèn)證方式保護(hù)我們的授權(quán)服務(wù)。

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

    @Bean
    UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

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

資源服務(wù)器

本節(jié)中我們使用 Spring Security 5 設(shè)置OAuth2 受保護(hù)資源服務(wù)。通過自定義實(shí)現(xiàn)AuthenticationManagerResolver將 JWT 與內(nèi)省協(xié)議結(jié)合使用。

Maven 依賴

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.6.7</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>2.6.7</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
            <version>2.6.7</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
            <version>2.6.7</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
            <version>2.6.7</version>
        </dependency>
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <version>2.9.3</version>
        </dependency>
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>oauth2-oidc-sdk</artifactId>
            <version>9.43.1</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.3</version>
        </dependency>

配置

首先通過application.yml配置數(shù)據(jù)庫(kù)連接和服務(wù)端口。

server:
  port: 8090

spring:
  application:
    name: auth-server
  datasource:
    druid:
      db-type: mysql
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/resourceserver-introspection?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
      username: <<username>> # update user
      password: <<password>> # update password

以往我們配置受保護(hù)資源服務(wù)通常會(huì)在application.yml中指定 spring.security.resourceserver.jwtspring.security.resourceserver.opaquetoken配置,
Spring Security 會(huì)使用JwtAuthenticationProviderOpaqueTokenAuthenticationProvider 驗(yàn)證access_token 。

本節(jié)中我們將根據(jù)AuthenticationManagerResolver獲取驗(yàn)證access_token規(guī)則。由于issuer伴隨著已簽署的JWT,因此可以使用JwtIssuerAuthenticationManagerResolver完成。
我們將創(chuàng)建 AuthenticationManagerResolver的實(shí)現(xiàn)IntrospectiveIssuerJwtAuthenticationManagerResolver 作為參數(shù)構(gòu)造 JwtIssuerAuthenticationManagerResolver 。

public class IntrospectiveIssuerJwtAuthenticationManagerResolver implements AuthenticationManagerResolver<String> {

    private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();

    private final OAuth2IntrospectionService introspectionService;

    private final OpaqueTokenIntrospectorSupport opaqueTokenIntrospectorSupport;

    public IntrospectiveIssuerJwtAuthenticationManagerResolver(OAuth2IntrospectionService introspectionService,
                                                               OpaqueTokenIntrospectorSupport opaqueTokenIntrospectorSupport) {
        Assert.notNull(introspectionService, "introspectionService can be not null");
        Assert.notNull(opaqueTokenIntrospectorSupport, "opaqueTokenIntrospectorSupport can be not null");
        this.introspectionService = introspectionService;
        this.opaqueTokenIntrospectorSupport = opaqueTokenIntrospectorSupport;
    }

    @Override
    public AuthenticationManager resolve(String issuer) {
        OAuth2Introspection oAuth2Introspection = this.introspectionService.loadIntrospection(issuer);

        if (oAuth2Introspection != null) {
            AuthenticationManager authenticationManager = this.authenticationManagers.computeIfAbsent(issuer,
                    (k) -> {
                        log.debug("Constructing AuthenticationManager");
                        OpaqueTokenIntrospector opaqueTokenIntrospector = this.opaqueTokenIntrospectorSupport.fromOAuth2Introspection(oAuth2Introspection);
                        return new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector)::authenticate;
                    });
            log.debug(LogMessage.format("Resolved AuthenticationManager for issuer '%s'", issuer).toString());
            return authenticationManager;

        } else {
            log.debug("Did not resolve AuthenticationManager since issuer is not trusted");
        }
        return null;
    }
}

OAuth2IntrospectionService管理OAuth2Introspection并負(fù)責(zé)持久化。在 OAuth2Introspection 中包含了issuer,clientId,clientSecret,introspectionUri屬性信息。

OpaqueTokenIntrospectorSupport負(fù)責(zé)根據(jù) OAuth2Introspection 創(chuàng)建 OpaqueTokenIntrospector,用于 OAuth 2.0 令牌的內(nèi)省和驗(yàn)證。 OpaqueTokenIntrospector此接口的實(shí)現(xiàn)將向 OAuth 2.0 內(nèi)省端點(diǎn)發(fā)出請(qǐng)求以驗(yàn)證令牌并返回其屬性。在使用令牌內(nèi)省會(huì)導(dǎo)致 OAuth 2.0 系統(tǒng)內(nèi)的網(wǎng)絡(luò)流量增加,
為了解決這個(gè)問題,我們可以允許受保護(hù)資源緩存給定令牌的內(nèi)省請(qǐng)求結(jié)果。我們將創(chuàng)建 OpaqueTokenIntrospector 的緩存實(shí)現(xiàn) CachingOpaqueTokenIntrospector。建議設(shè)置短于令牌生命周期的緩存有效期,以便降低令牌被撤回但緩存還有效的可能性。

public class CachingOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private final Cache cache;

    private final OpaqueTokenIntrospector introspector;

    public CachingOpaqueTokenIntrospector(Cache cache, OpaqueTokenIntrospector introspector) {
        this.cache = cache;
        this.introspector = introspector;
    }

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        try {
            return this.cache.get(token,
                    () -> this.introspector.introspect(token));
        } catch (Cache.ValueRetrievalException ex) {
            throw new OAuth2IntrospectionException("Did not validate token from cache.");
        } catch (OAuth2IntrospectionException e) {
            if (e instanceof BadOpaqueTokenException) {
                throw (BadOpaqueTokenException) e;
            }
            throw new OAuth2IntrospectionException(e.getMessage());
        } catch (Exception ex) {
            log.error("Token introspection failed.", ex);
            throw new OAuth2IntrospectionException("Token introspection failed.");
        }
    }
}

接下來我們創(chuàng)建 OAuth2IntrospectiveResourceServerAuthorizationConfigurer 繼承 AbstractHttpConfigurer,實(shí)現(xiàn)我們的定制化配置。

public class OAuth2IntrospectiveResourceServerAuthorizationConfigurer extends AbstractHttpConfigurer<OAuth2IntrospectiveResourceServerAuthorizationConfigurer, HttpSecurity> {

    //...

    @Override
    public void init(HttpSecurity http) throws Exception {
        this.validateConfiguration();
        ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
        if (this.authenticationManagerResolver == null) {
            OAuth2IntrospectionService oAuth2IntrospectionService = applicationContext.getBean(OAuth2IntrospectionService.class);
            OpaqueTokenIntrospectorSupport opaqueTokenIntrospectorSupport = this.getOpaqueTokenIntrospectorSupport(applicationContext);

            IntrospectiveIssuerJwtAuthenticationManagerResolver introspectiveIssuerJwtAuthenticationManagerResolver =
                    new IntrospectiveIssuerJwtAuthenticationManagerResolver(oAuth2IntrospectionService, opaqueTokenIntrospectorSupport);
            this.authenticationManagerResolver = introspectiveIssuerJwtAuthenticationManagerResolver;
        }
        JwtIssuerAuthenticationManagerResolver jwtIssuerAuthenticationManagerResolver =
                new JwtIssuerAuthenticationManagerResolver(this.authenticationManagerResolver);
        http.oauth2ResourceServer(oauth2 -> oauth2
                .authenticationManagerResolver(jwtIssuerAuthenticationManagerResolver)
        );
    }

    //...
}

最后定義Spring Security安全配置類,通過http.apply()加載定制化配置OAuth2IntrospectiveResourceServerAuthorizationConfigurer。同時(shí)定義
保護(hù)端點(diǎn) /resource/article 權(quán)限為 message.read

@Configuration(proxyBeanMethods = false)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .mvcMatchers("/resource/article").hasAuthority("SCOPE_message.read")
                        .anyRequest().authenticated()
                )
                .apply(new OAuth2IntrospectiveResourceServerAuthorizationConfigurer())
                .opaqueTokenIntrospectorSupport();
        return http.build();
    }
}

篇幅限制本節(jié)中涉及代碼都取自片段,源碼附在文末 鏈接 中。

測(cè)試

Spring Security 構(gòu)造 OAuth2.0 客戶端服務(wù)流程文中并沒有介紹,如果您對(duì)此有疑問,可以參考以前文章 或從文末 鏈接 中獲取源碼。

我們將服務(wù)啟動(dòng)后,瀏覽器訪問 http://127.0.0.1:8070/client/test,通過認(rèn)證(用戶名密碼為admin/password)并同意授權(quán)后,您將看到如下最終結(jié)果:

{
    "sub": "admin",
    "articles": ["Effective Java", "Spring In Action"]
}

結(jié)論

與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容