使用Spring Security资源服务器来保护Spring Cloud微服务

我在上一篇对资源服务器进行了简单的阐述,让大家对资源服务器的概念有了简单的认识,今天我将用实际例子来演示单体应用改造为Spring Cloud微服务时的资源服务器实现。

资源服务器改造

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解码器。接下来我们一步步来实现它。

分离公私钥

资源服务器只能保存公钥,所以需要从之前的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将由Bearer Token来保护。

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

附加说明

为了测试资源服务器,假设我们有一个颁发令牌的授权服务器。这里简单模拟了一个发令牌的方法用来获取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);
}

相关的DEMO已经上传,你可以通过关注公众号“码农小胖哥”,回复 resourceserver获取资源服务器实现的DEMO。

好了,今天的学习就到这里!如果您学习过程中如遇困难?可以加入我们超高质量的Spring技术交流群,参与交流与讨论,更好的学习与进步!更多Spring Cloud教程可以点击直达!,欢迎收藏与转发支持!

上一篇:Spring Boot 2.x基础教程:使用LDAP来管理用户与组织数据


下一篇:使用 Spring 2.5 基于注解驱动的 Spring MVC