OAuth2.0资源服务器干嘛用如何用

3,579 阅读7分钟

小知识,大挑战!本文正在参与“    程序员必备小知识    ”创作活动

本文同时参与 「掘力星计划」    ,赢取创作大礼包,挑战创作激励金

下方留言,掘金官方送周边啦 博文中哪里写的不好的欢迎评论区指出 掘金官方抽送100个周边啦

资源服务器到底是什么以及怎么用很少有教程来专门聊这个东西,今天我们来聊一聊这个概念。

认证和访问控制是可以松耦合的

用户的认证和资源的访问控制其实是可以分开的。就比如你买飞机票,现在你不仅可以在航空公司的售票部门买到票,也可以到第三方票务中心线下或者线上去买票,最终每个架次的航班会对你的票据进行核验以确定你是否符合登记的条件,而且不会关心你的购票渠道。这是实际生活中的一个例子。

如果在微服务中,我们每一个服务只需要校验请求是否具有符合访问资源的权限即可,我们可以把资源访问校验的逻辑抽象一个公用的模型嵌入每一个服务中去,非常符合微服务去中心化的思想。这就是资源服务器的一个典型用法。

资源服务器

资源服务器的全称是 OAuth2 Resource Server ,它实际上是OAuth 2.0 协议的一部分,通常我们借助于Json Web Token来实现。 OAuth2.0授权服务器负责发“证件”,资源服务器负责对“证件”进行校验。在去中心化的架构中,每一个API服务本身也承担资源服务器的功能。

作为授权服务一般还是中心化比较好,统一管理用户的认证授权并发放给客户端Token。每个具体的服务自行承担对Token的校验功能,我们只需要抽象好访问控制的接口就可以了。大致的流程图如下:

微服务认证授权流程

这样授权服务器只管发Token功能,资源服务器只负责验证Token,每当有新的服务接入我们只需要加入配套的资源服务依赖和配置即可,改造起来非常简单。

网上还有一种资源服务器也中心化的方式,也就是在网关处进行集中认证处理。个人认为除非你有过类似经验,否则并不容易接受,而且还要处理一些安全上下文跨服务的问题。对于初学者来说强烈建议使用以上的模型。

资源服务器改造

Spring Security实战干货的DEMO为例子,原本它是一个单体应用,认证和授权都在一个应用中使用。改造为独立的服务后,原本的认证就要剥离出去(这个后续再讲如何实现),服务将只保留基于用户凭证(JWT)的访问控制功能。接下来我们将一步步来实现该能力。

所需依赖

在Spring Security的基础上,我们需要加入新的依赖来支持OAuth2 Resource Server和JWT。我们需要引入下面几个依赖库:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- 资源服务器 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
       <!-- jose -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>

Spring Security 5.x 移除了OAuth2.0授权服务器,保留了OAuth2.0资源服务器。

JWT解码

要校验JWT就必须实现对JWT的解码功能,在Spring Security OAuth2 Resource Server模块中,默认提供了解码器,这个解码器需要调用基于:

spring.security.oauth2.resourceserver

配置下的元数据来生成解码配置,这里的配置大部分是调用授权服务器开放的well-known端点,包含了解析验证JWT一系列参数:

  • jwkSetUri 一般是授权服务器提供的获取JWK配置的well-known端点,用来校验JWT Token。
  • jwsAlgorithm 指定jwt使用的算法,默认 RSA-256
  • issuerUri 获取OAuth2.0 授权服务器元数据的端点。
  • publicKeyLocation 用于解码的公钥路径,作为资源服务器来说将只能持有公钥,不应该持有私钥。

为了实现平滑过渡,默认的配置肯定不能用了,需要定制化一个JWT解码器,重点就是实现如何从本地公钥证书初始化JWK。接下来我们一步步来实现这一步骤。

分离公私钥

资源服务器只能保存公钥,所以需要从之前的jks文件中导出一个公钥。

 keytool -export -alias felordcn -keystore <jks证书全路径>  -file <导出cer的全路径>

例如:

 keytool -export -alias felordcn -keystore D:\keystores\felordcn.jks  -file d:\keystores\publickey.cer

把分离的cer公钥文件放到原来jks文件的路径下面,资源服务器不再保存jks

自定义jwt解码器

spring-security-oauth2-jose是Spring Security的jose规范依赖。我将根据该类库来实现自定义的JWT解码器。

    /**
     * 基于Nimbus的jwt解码器,并增加了一些自定义校验策略
     *
     * @param validator the validator
     * @return the jwt decoder
     */
    @SneakyThrows
    @Bean
    public JwtDecoder jwtDecoder(@Qualifier("delegatingTokenValidator") DelegatingOAuth2TokenValidator<Jwt> validator) {
        CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
        // 从classpath路径读取cer公钥证书来配置解码器
        ClassPathResource resource = new ClassPathResource(this.jwtProperties.getCertInfo().getPublicKeyLocation());
        Certificate certificate = certificateFactory.generateCertificate(resource.getInputStream());
        PublicKey publicKey = certificate.getPublicKey();
        NimbusJwtDecoder nimbusJwtDecoder = NimbusJwtDecoder.withPublicKey((RSAPublicKey) publicKey).build();
        nimbusJwtDecoder.setJwtValidator(validator);
        return nimbusJwtDecoder;
    }

上面的解码器基于我们的公钥证书,同时我还自定义了一些校验策略。不得不说Nimbus的jwt类库比jjwt要好用的多。

自定义资源服务器配置

接下来配置资源服务器。

核心流程和概念

资源服务器其实也就是配置了一个过滤器BearerTokenAuthenticationFilter来拦截并验证Bearer Token。验证通过而且权限符合要求就放行,不通过就不放行。

和之前不太一样的是验证成功后凭据不再是UsernamePasswordAuthenticationToken而是JwtAuthenticationToken

@Transient
public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken<Jwt> {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private final String name;

	/**
	 * Constructs a {@code JwtAuthenticationToken} using the provided parameters.
	 * @param jwt the JWT
	 */
	public JwtAuthenticationToken(Jwt jwt) {
		super(jwt);
		this.name = jwt.getSubject();
	}

	/**
	 * Constructs a {@code JwtAuthenticationToken} using the provided parameters.
	 * @param jwt the JWT
	 * @param authorities the authorities assigned to the JWT
	 */
	public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
		super(jwt, authorities);
		this.setAuthenticated(true);
		this.name = jwt.getSubject();
	}

	/**
	 * Constructs a {@code JwtAuthenticationToken} using the provided parameters.
	 * @param jwt the JWT
	 * @param authorities the authorities assigned to the JWT
	 * @param name the principal name
	 */
	public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities, String name) {
		super(jwt, authorities);
		this.setAuthenticated(true);
		this.name = name;
	}

	@Override
	public Map<String, Object> getTokenAttributes() {
		return this.getToken().getClaims();
	}

	/**
	 * jwt 中的sub 值  用户名比较合适
	 */
	@Override
	public String getName() {
		return this.name;
	}

}

这个我们改造的时候要特别注意,尤其是从SecurityContext获取的时候用户凭证信息的时候。

资源管理器配置

从Spring Security 5的某版本开始不需要再集成适配类了,只需要这样就能配置Spring Security,资源管理器也是这样:

    @Bean
    SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeRequests(request -> request.anyRequest()
                        .access("@checker.check(authentication,request)"))
                .exceptionHandling()
                .accessDeniedHandler(new SimpleAccessDeniedHandler())
                .authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
                .and()
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .build();
    }

这里只需要声明使用JWT校验的资源服务器,同时配置好定义的401端点和403处理器即可。这里我加了基于SpEL的动态权限控制,这个以往都讲过了,这里不再赘述。

JWT个性化解析

从JWT Token中解析数据并生成JwtAuthenticationToken的操作是由JwtAuthenticationConverter来完成的。你可以定制这个转换器来实现一些个性化功能。比如默认情况下解析出来的权限都是带SCOPE_前缀的,而项目用ROLE_,你就可以通过这个类兼容一下老项目。

     @Bean
    JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
//        如果不按照规范  解析权限集合Authorities 就需要自定义key
//        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("scopes");
//        OAuth2 默认前缀是 SCOPE_     Spring Security 是 ROLE_
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        // 设置jwt中用户名的key  默认就是sub  你可以自定义
        jwtAuthenticationConverter.setPrincipalClaimName(JwtClaimNames.SUB);
        return jwtAuthenticationConverter;
    }

这里基本上就改造完成了,当带着令牌来访问API时,资源服务器会对令牌进行校验以进行访问控制。

在实际生产中建议把资源服务器封装为依赖集成到需要保护资源的的服务中即可。

附加说明

为了测试资源服务器,假设我们有一个颁发令牌的授权服务器。这里简单模拟了一个发令牌的方法用来获取Token:

    /**
     * 资源服务器不应该生成JWT 但是为了测试 假设这是个认证服务器
     */
    @SneakyThrows
    @Test
    public void imitateAuthServer() {

        JwtEncoder jwsEncoder = new NimbusJwsEncoder(jwkSource());

        JwtTokenGenerator jwtTokenGenerator = new JwtTokenGenerator(jwsEncoder);
        OAuth2AccessTokenResponse oAuth2AccessTokenResponse = jwtTokenGenerator.tokenResponse();

        System.out.println("oAuth2AccessTokenResponse = " + oAuth2AccessTokenResponse.getAccessToken().getTokenValue());
    }
    
        @SneakyThrows
    private JWKSource<SecurityContext> jwkSource() {
        ClassPathResource resource = new ClassPathResource("felordcn.jks");
        KeyStore jks = KeyStore.getInstance("jks");
        String pass = "123456";
        char[] pem = pass.toCharArray();
        jks.load(resource.getInputStream(), pem);

        RSAKey rsaKey = RSAKey.load(jks, "felordcn", pem);

        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }