SpringSecurity OAuth2 (8) 自定义: ResourceServerTokenServices 资源服务器自行验证签名并解析令牌

序言

之前的资源服务器都是通过 RemoteTokenServices 请求授权服务器的 /oauth/check_token 端点解析令牌的. 我们假设有 N 个资源服务器, 在高并发的情况下, 每来一个携带令牌的请求都去向授权服务器申请解析一次令牌, 即使授权服务器做了高可用, 这其中的网络开销, 对授权服务器的压力等诸多不稳定因素是我们不得不考虑, 有没有一种方案, 让我们的资源服务器自行校验 JWT 的签名, 实现一定意义上的 “自治”?

这便是本篇要探讨的内容…

本篇基本上主要探讨资源服务器相关特性, 以及如何实现 “自治”.

资源服务器: 自定义 ResourceServerTokenServices

上一篇 中, 资源服务器使用的是 ResourceServerTokenServices 其中一个实现: RemoteTokenServices, 其提供了资源服务器访问远端授权服务器解析令牌的端口. 而同样身为 ResourceServerTokenServices 实现的 DefaultTokenServices 则被广泛用于授权服务器中, 因为它同时实现了 AuthorizationServerTokenServicesResourceServerTokenServices, 进而既具备了颁发令牌, 签名令牌的能力, 也具备了接收请求解析令牌, 验证令牌签名的能力, 因此很适合用于授权服务器, 或是授权资源服务一体的场景.

经过分析, 看起来我们需要实现自己的 resourceServerTokenServices, 它需要具备如下能力:

  1. 获取公钥 (用于验证 JWT 的签名):
    1. 本地获取 (资源服务器端需要公钥文件);
    2. 请求授权服务器提供的获取公钥的端点 TokenKeyEndpoint (/oauth/token_key), 需要注意的是此端点默认不启用, 需要用户手动将其注入到容器中 (稍后会有介绍);
  2. “独立” 解析令牌 (不依赖于请求授权服务器);

(由于是资源服务器自行处理逻辑, 所以客户端凭证的验证就没必要了, 因为此时的客户端凭证不正是资源服务器自己嘛…)


ResourceServerTokenServices 定义了两个签名方法:

  1. OAuth2Authentication loadAuthentication(String accessToken) 用于从指定的令牌字符串中抽取认证信息, 构建 OAuth2Authentication 对象.

  2. OAuth2AccessToken readAccessToken(String accessToken) 仅用于 CheckTokenEndpoint 端点, 后者用于在授权服务器接收资源服务器的请求校验令牌. 所以对于资源服务器来说, 并不需要实现它.

自定义 - CustomResourceServerTokenServices

综上所述, 我们需要实现 ResourceServerTokenServices 的 loadAuthentication 接口方法, 该方法会在 OAuth2AuthenticationProcessingFilter(对于被 OAuth2 保护的资源来说, 它扮演着一个预认证的过滤器, 它承担着从请求中抽取令牌, 然后构建 OAuth2Authentication 对象进而生成 SpringSecurity 安全上下文的职能) 的 doFilter 被调用: AuthenticationManager.authenticate(Authentication), 其中这个 AuthenticationManager 的真实类型是 OAuth2AuthenticationManager, 这部分源码摘录:

public class OAuth2AuthenticationManager implements AuthenticationManager, InitializingBean {
    // ...
    
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		if (authentication == null) {
			throw new InvalidTokenException("Invalid token (token not found)");
		}
		String token = (String) authentication.getPrincipal();
		OAuth2Authentication auth = tokenServices.loadAuthentication(token);
		if (auth == null) {
			throw new InvalidTokenException("Invalid token: " + token);
		}

		Collection<String> resourceIds = auth.getOAuth2Request().getResourceIds();
		if (resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(resourceId)) {
			throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + resourceId + ")");
		}

		checkClientDetails(auth);

		if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
			OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
			// Guard against a cached copy of the same details
			if (!details.equals(auth.getDetails())) {
				// Preserve the authentication details from the one loaded by token services
				details.setDecodedDetails(auth.getDetails());
			}
		}
		auth.setDetails(authentication.getDetails());
		auth.setAuthenticated(true);
		return auth;
	}
    
    // ...
}

在 SpringSecurity OAuth2 提供的 RemoteTokenServices 中, 就是在 loadAuthentication 方法中远程调用授权服务器的解析令牌端点的. 对于我们自定义的 tokenServices, 这个逻辑需要在且仅在资源服务器本身完成.


接下来, 我们就来配置并实现自定义的 tokenServices: CustomResourceServerTokenServices:

首先是 ResourceServerConfiguration:

/**
 * 资源服务器配置
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-06-13 20:55
 */
@Slf4j
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    /**
     * 资源服务器保存的持有公钥的文件名
     */
    private static final String AUTHORIZATION_SERVER_PUBLIC_KEY_FILENAME = "authorization-server.pub";

    /**
     * 资源服务器 ID
     */
    private static final String RESOURCE_ID = "resource-server";

    /**
     * 授权服务器的 {@link org.springframework.security.oauth2.provider.endpoint.TokenKeyEndpoint} 供资源服务器请求授权服务器获取公钥的端点<br>
     * 在资源服务器中, 可以有两种方式获取授权服务器用于签名 JWT 的私钥对应的公钥:
     * <ol>
     *     <li>本地获取 (需要公钥文件)</li>
     *     <li>请求授权服务器提供的端点 (/oauth/token_key) 获取</li>
     * </ol>
     */
    private static final String AUTHORIZATION_SERVER_TOKEN_KEY_ENDPOINT_URL = "http://localhost:18957/token-customize-authorization-server/oauth/token_key";

    // =================================================================================================================

    private AuthenticationEntryPoint authenticationEntryPoint;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        // @formatter:off
        resources.resourceId(RESOURCE_ID).stateless(true);

        // ~ 指定 ResourceServerTokenServices
        resources.tokenServices(new CustomResourceServerTokenServices(jwtAccessTokenConverter()));

        // ~ AuthenticationEntryPoint. ref: OAuth2AuthenticationProcessingFilter
        resources.authenticationEntryPoint(authenticationEntryPoint);
        // @formatter:on
    }

    // ~ TokenStore

    /**
     * Description: 为签名验证和解析提供转换器<br>
     * Details: 看起来 {@link org.springframework.security.jwt.crypto.sign.RsaVerifier} 已经被标记为过时了, 究其原因, 似乎 Spring 已经发布了一个新的产品 Spring Authorization Server, 有空再研究.
     *
     * @see <a href="https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide">OAuth 2.0 Migration Guide</a>
     * @see <a href="https://spring.io/blog/2020/04/15/announcing-the-spring-authorization-server">Announcing the Spring Authorization Server</a>
     * @see JwtAccessTokenConverter
     */
    @SuppressWarnings("deprecation")
    private JwtAccessTokenConverter jwtAccessTokenConverter() {
        final JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setVerifier(new org.springframework.security.jwt.crypto.sign.RsaVerifier(retrievePublicKey()));
        return jwtAccessTokenConverter;
    }

    /**
     * Description: 获取公钥 (Verifier Key)<br>
     * Details: 启动时调用
     *
     * @return java.lang.String
     * @author LiKe
     * @date 2020-07-22 11:45:40
     */
    private String retrievePublicKey() {
        final ClassPathResource classPathResource = new ClassPathResource(AUTHORIZATION_SERVER_PUBLIC_KEY_FILENAME);
        try (
                // ~ 先从本地取读取名为 authorization-server.pub 的公钥文件, 获取公钥
                final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(classPathResource.getInputStream()))
        ) {
            log.debug("{} :: 从本地获取公钥 ...", RESOURCE_ID);
            return bufferedReader.lines().collect(Collectors.joining("\n"));
        } catch (IOException e) {
            // ~ 如果本地没有, 则尝试通过授权服务器的 /oauth/token_key 端点获取公钥
            log.debug("{} :: 从本地获取公钥失败: {}, 尝试从授权服务器 /oauth/token_key 端点获取 ...", RESOURCE_ID, e.getMessage());
            final RestTemplate restTemplate = new RestTemplate();
            final String responseValue = restTemplate.getForObject(AUTHORIZATION_SERVER_TOKEN_KEY_ENDPOINT_URL, String.class);

            log.debug("{} :: 授权服务器返回原始公钥信息: {}", RESOURCE_ID, responseValue);
            return JSON.parseObject(JSON.parseObject(responseValue).getString("data")).getString("value");
        }
    }

    // =================================================================================================================

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
    }

    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setAuthenticationEntryPoint(@Qualifier("customAuthenticationEntryPoint") AuthenticationEntryPoint authenticationEntryPoint) {
        this.authenticationEntryPoint = authenticationEntryPoint;
    }
}

可以看到, 我们为 ResourceServerSecurityConfigurer 置入了自定的 tokenServices, 其构造需要一个令牌转换器, 传入公钥. 这里公钥的获得的方式有二: 本地或是授权服务器. 并且这段代码在资源服务器启动的时候就会且仅会执行一次, 所以即使是需要访问授权服务器获取公钥, 也在启动时仅需要一次. 如果是从本地直接能获取到公钥, 那整个对授权服务器的携带令牌的访问过程都不需要授权服务器介入. 较之前的采用 RemoteTokenServices 每次请求都需要向授权服务器申请解析认证, 是不是大大降低了授权服务器的压力呢? 肯定的!

下面是自定义的 ResourceServerTokenServices 的代码:

/**
 * 自定义的 {@link ResourceServerTokenServices}
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-07-24 09:27
 */
@Slf4j
public class CustomResourceServerTokenServices implements ResourceServerTokenServices {

    private final TokenStore tokenStore;

    public CustomResourceServerTokenServices(JwtAccessTokenConverter accessTokenConverter) {
        this.tokenStore = new JwtTokenStore(accessTokenConverter);
    }

    /**
     * Description: 用于从 accessToken 中加载凭证信息, 并构建出 {@link OAuth2Authentication} 的方法<br>
     *     Details:
     *
     * @see ResourceServerTokenServices#loadAuthentication(String)
     */
    @Override
    public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException {
        log.debug("CustomResourceServerTokenServices :: loadAuthentication called ...");
        log.trace("CustomResourceServerTokenServices :: loadAuthentication :: accessToken: {}", accessToken);

        return tokenStore.readAuthentication(accessToken);
    }

    @Override
    public OAuth2AccessToken readAccessToken(String accessToken) {
        log.debug("CustomResourceServerTokenServices :: readAccessToken called ...");
        throw new UnsupportedOperationException("暂不支持 readAccessToken!");
    }

}

可以看到, 我们为 tokenServices 在构造阶段就初始化了一个 JwtTokenStore 的实例, 后者会调用 readAuthentication 方法解析令牌并组织 OAuth2Authentication 对象:

public class JwtTokenStore implements TokenStore {
	// ...

	@Override
	public OAuth2Authentication readAuthentication(String token) {
		return jwtTokenEnhancer.extractAuthentication(jwtTokenEnhancer.decode(token));
	}

	// ...
}

这样, 资源服务器的改造就算结束了. 为了让资源服务器能够访问授权服务器的 /oauth/token_key 端点, 我们还需要在授权服务器上, 开启这个端点, 请接着往下看…

授权服务器: 注入 TokenKeyEndpoint

前面也说到了, 如果资源服务器本地没有公钥文件, 它还可以请求授权服务器的公钥端点 TokenKeyEndpoint 端点获取公钥. 但是需要授权服务器开启了这个端点, 这就是本章要讨论的内容.

TokenKeyEndpoint 对应端点 /oauth/token_key, 是供外部调用的, 获取 “公钥” 的端点. 在 org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration 中我们看到, 默认情况下, 注入了 TokenEndpoint (/oauth/token), CheckTokenEndpoint (/oauth/check_token), AuthorizationEndpoint (/oauth/authorize), WhitelabelApprovalEndpointWhitelabelErrorEndpoint (/oauth/error) 这几个端点, 唯独没有我们需要的 TokenKeyEndpoint. 正因如此, 现在访问 /oauth/token_key 端点会直接响应 404. 看来, 这个端点需要用户手动注入.

TokenKeyEndpoint 源代码可以看到, 这个端点构造时接收 JwtAccessTokenConverter, 正好是我们配置的转换器.

@FrameworkEndpoint
public class TokenKeyEndpoint {
    private final JwtAccessTokenConverter converter;

 	public TokenKeyEndpoint(JwtAccessTokenConverter converter) {
		super();
		this.converter = converter;
	}

    /**
     * Get the verification key for the token signatures. The principal has to
     * be provided only if the key is secret
     * (shared not public).
     * 
     * @param principal the currently authenticated user if there is one
     * @return the key used to verify tokens
     */
    @RequestMapping(value = "/oauth/token_key", method = RequestMethod.GET)
    @ResponseBody
    public Map<String, String> getKey(Principal principal) {
        if ((principal == null || principal instanceof AnonymousAuthenticationToken) && !converter.isPublic()) {
            throw new AccessDeniedException("You need to authenticate to see a shared key");
        }
        Map<String, String> result = converter.getKey();
        return result;
    }
}

而调用这个转换器的 getKey 方法将返回公钥:

/**
 * Get the verification key for the token signatures.
 *
 * @return the key used to verify tokens
 */
public Map<String, String> getKey() {
	Map<String, String> result = new LinkedHashMap<String, String>();
	result.put("alg", signer.algorithm());
	result.put("value", verifierKey);
	return result;
}

我们可以继承这个转换器然后重写这个方法返回统一的响应格式:

/**
 * 自定义的 {@link JwtAccessTokenConverter}
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-07-22 10:19
 */
public class CustomJwtAccessTokenConverter extends JwtAccessTokenConverter {

    /**
     * Description: 重写以返回统一的格式
     *
     * @return java.util.Map<java.lang.String, java.lang.String>
     * @author LiKe
     * @date 2020-07-22 10:23:04
     * @see JwtAccessTokenConverter#getKey()
     */
    @Override
    public Map<String, String> getKey() {
        return SecurityResponse.Builder.of().httpStatus(HttpStatus.OK).message(HttpStatus.OK.getReasonPhrase())
                .data(super.getKey())
                .build().toMap();
    }
}

在授权服务器的配置类中, 手动注入 TokenKeyEndpoint 并将 JwtAccessTokenConverter 的引用指向自定义的转换器:

@Slf4j
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
	// ...

    /**
     * Description: 为 {@link JwtTokenStore} 所须
     *
     * @return org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter
     * @author LiKe
     * @date 2020-07-20 18:04:48
     */
    private JwtAccessTokenConverter jwtAccessTokenConverter() {
        final KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("authorization-server.jks"), "********".toCharArray());
        final JwtAccessTokenConverter jwtAccessTokenConverter = new CustomJwtAccessTokenConverter();
        jwtAccessTokenConverter.setKeyPair(keyStoreKeyFactory.getKeyPair("authorization-server-jwt-keypair"));
        return jwtAccessTokenConverter;
    }

    @Bean
    public TokenKeyEndpoint tokenKeyEndpoint() {
        return new TokenKeyEndpoint(jwtAccessTokenConverter());
    }

	// ...
}

测试 /oauth/token_key 端点, 授权服务器返回的数据应当形如:

{
    "timestamp": "2020-07-22 10:38:34",
    "status": "200",
    "message": "OK",
    "data": "{\"alg\":\"SHA256withRSA\",\"value\":\"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlLx5bz3zu/ptZpVuvCBQZ4dMeDhmZJmyxia7A9706B5o/ipLFcZnjOtKVQcZTa8UOniTDJ46DmMyK2Q5oW8d24cpMdPSwxNMU/7dOv40DFnoFUFIWUR/+fAZVTCfJb7pBpzWpmLmvOhLV8rSOKbJTIeRUWgsFZsCJJaqIa3/6k7moTV4DURUgh1ABmMyXUd3/zeSkdPJXu9QCdxFygSPVJs4d5Bqr97mROIdt9qmngap1Lch2elwrzWuQx63mGxoK+lxEQB6ftdPLvpEABuCBs7hO18CBj5ei9G+foaFe/77muNCILAtvc8UiD6PRbf5e1YXEp0IHZisuOhedjqBFQIDAQAB\\n-----END PUBLIC KEY-----\"}"
}

接下来可以启动授权服务器和资源服务器, 用向授权服务器申请的令牌请求资源服务器的资源, 可以观察到, 整个过程授权服务器都没有一行日志输出. 可见, 授权和验证, 已经实现了解耦. 授权服务器也得到了 “减负”.

总结

本篇探讨了如何在资源服务器通过自定义的 tokenServices 独立实现令牌的解析和签名认证 (不需要授权服务器介入).

下一篇, 我们将尝试研究一下如何在 SpringSecurity OAuth2 实现客户端和用户端, 两端的动态权限…

Reference

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值