Spring Security OAuth2 内省协议与 JWT 结合使用指南

Spring Security OAuth2 内省协议与 JWT 结合使用指南

概述

我们已经熟悉两种用于授权服务器和受保护资源之间传递信息的方法:JWT(JSON Web Token)和令牌内省。
但实际上,将它们结合起来使用也可以得到很好的效果。尤其在受保护资源要接受来自多个授权服务器的令牌的情况下特别有用。受保护资源可以先解析 JWT,弄清楚
令牌颁发自哪一个授权服务器,然后向对应的授权服务器发送内省请求以获取详细信息。

这篇文章将介绍如何实现Spring Security 5设置资源服务器实现内省协议与JWT的结合使用,让我们开始实践吧!

授权服务器

在本节中我们将使用 Spring Authorization Server 搭建授权服务器,访问令牌格式为
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指定服务端口:

server:
  port: 8080

接下来我们创建AuthorizationServerConfig配置类,在此类中我们将创建授权服务所需Bean。下面我们将为授权服务器创建一个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 客户端参数信息说明如下:

  • id: 唯一标识
  • clientId: 客户端标识符
  • clientSecret: 客户端秘密
  • clientAuthenticationMethods: 客户端可能使用的身份验证方法。支持的值为client_secret_basicclient_secret_postprivate_key_jwtclient_secret_jwtnone
  • authorizationGrantTypes: 客户端可以使用的授权类型。支持的值为authorization_codeimplicitpasswordclient_credentialsrefresh_tokenurn:ietf:params:oauth:grant-type:jwt-bearer
  • redirectUris: 客户端已注册重定向 URI
  • scopes: 允许客户端请求的范围
  • clientSettings: 客户端的自定义设置
    • requireAuthorizationConsent: 是否需要授权统同意
    • requireProofKey: 当参数为true时,该客户端支持PCKE
  • tokenSettings: OAuth2 令牌的自定义设置
    • accessTokenFormat: 访问令牌格式,支持OAuth2TokenFormat.SELF_CONTAINED(自包含的令牌使用受保护的、有时间限制的数据结构,例如JWT);OAuth2TokenFormat.REFERENCE(不透明令牌)
    • accessTokenTimeToLive: access_token有效期
    • refreshTokenTimeToLive: refresh_token有效期
    • reuseRefreshTokens: 是否重用刷新令牌。当参数为true时,刷新令牌后不会重新生成新的refreshToken

ProviderSettings包含OAuth2授权服务器的配置设置。它指定了协议端点的URI以及发行人标识。此处issuer在下文将由受保护资源解析用于区分授权服务器。

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

我们将通过OAuth2AuthorizationServerConfiguration将OAuth2默认安全配置应用于HttpSecurity,同时对于未认证请求重定向到登录页面。

    @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();
    }

授权服务器需要其用于JWT令牌的签名密钥,让我们生成一个的 RSA 密钥:

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

最后我们将定义Spring Security安全配置类,定义Form表单认证方式保护我们的授权服务。

    @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();
    }

资源服务器

本节中我们使用 Spring Security 5 设置OAuth2 受保护资源服务。通过自定义实现AuthenticationManagerResolver将 JWT 与内省协议结合使用。

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配置数据库连接和服务端口。

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

以往我们配置受保护资源服务通常会在application.yml中指定 spring.security.resourceserver.jwtspring.security.resourceserver.opaquetoken配置,
Spring Security 会使用JwtAuthenticationProviderOpaqueTokenAuthenticationProvider 验证access_token 。

本节中我们将根据AuthenticationManagerResolver获取验证access_token规则。由于issuer伴随着已签署的JWT,因此可以使用JwtIssuerAuthenticationManagerResolver完成。
我们将创建 AuthenticationManagerResolver的实现IntrospectiveIssuerJwtAuthenticationManagerResolver 作为参数构造 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并负责持久化。在 OAuth2Introspection 中包含了issuer,clientId,clientSecret,introspectionUri属性信息。

OpaqueTokenIntrospectorSupport负责根据 OAuth2Introspection 创建 OpaqueTokenIntrospector,用于 OAuth 2.0 令牌的内省和验证。 OpaqueTokenIntrospector此接口的实现将向 OAuth 2.0 内省端点发出请求以验证令牌并返回其属性。在使用令牌内省会导致 OAuth 2.0 系统内的网络流量增加,
为了解决这个问题,我们可以允许受保护资源缓存给定令牌的内省请求结果。我们将创建 OpaqueTokenIntrospector 的缓存实现 CachingOpaqueTokenIntrospector。建议设置短于令牌生命周期的缓存有效期,以便降低令牌被撤回但缓存还有效的可能性。

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.");
        }
    }
}

接下来我们创建 OAuth2IntrospectiveResourceServerAuthorizationConfigurer 继承 AbstractHttpConfigurer,实现我们的定制化配置。

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。同时定义
保护端点 /resource/article 权限为 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();
    }
}

篇幅限制本节中涉及代码都取自片段,源码附在文末 链接 中。

测试

Spring Security 构造 OAuth2.0 客户端服务流程文中并没有介绍,如果您对此有疑问,可以参考以前文章 或从文末 链接 中获取源码。

我们将服务启动后,浏览器访问 http://127.0.0.1:8070/client/test,通过认证(用户名密码为admin/password)并同意授权后,您将看到如下最终结果:

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

结论

与往常一样,本文中使用的源代码可在 GitHub 上获得。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容