看下源码修下 SpringSecurityOAuth2 的bug,解决令牌检查端点未实现 OAuth2 规范带来的坑: Resour…

最近在自己搭一个使用 SpringSecutiryOAuth2
的认证服务器, 这里的接口基于 SpringMVC, 而资源服务器是 SpringCloudGateway 建立的网关层,实现是 WebFlux。
目的是为了在网关层做所有的鉴权操作。  其实一切都还好,ajax 登陆、OAuth2密码模式的 token 获取、token刷新等 都有序进行中。
认证的整个流程都没发现问题,可是一到鉴权的阶段就不对劲了。
主要就是令牌内省失败,正常的令牌没问题,但是令牌如果一过期/或者是错误的令牌, 直接就报错了。这我就觉得很不对劲。

问题是我想了想 无论是 AuthorizationServer 还是 ResourceServer , 我都没有对具体认证流程作出改动。
仅仅实现了 SpringSecurity 提供的拓展点。比如 Token存储、Client存储、token 的附加信息、权限查询 之类的。
那这就不应该啊?? 我这用的你默认的实现,怎么还能有问题呢?
而又由于网关层 也就是OAuth2 ResourceServer 他是一个 WebFlux 搭建的web服务,  这个东西调试是真的不好调,里面大量的 lambda 和异步看的我头都要炸了。
不过又还能怎么办呢? ResourceServer/ AuthorizationServer 源码看看看看他丫的。 Debug日志开他丫的。
可是看他这个 WebFlux 下的鉴权源码真的很痛苦。 所以我具体详细的 Debug 流程就不细说了。

ResourceServer introspect

首先就是看 ResourceServer 的令牌内省(introspect)  也就是检查令牌的机制流程

具体触发时机为: 一个客户端试图来请求 ResourceServer 受保护的资源时、若是携带了 Authorization 请求头( Bearer ) 时则会触发令牌内省

最终我找到了处理token 鉴权具体类,就是它:
NimbusReactiveOpaqueTokenIntrospector

这里贴一段 WebFlux 作为资源服务器处理 token 鉴权的流程源码。
Gateway的过滤器会提取出 Bearer Token 然后调用此方法。每个流程都写了注释, 还是很清晰的。

@Override
public Mono introspect(String token) {
 
    return Mono.just(token)
            .flatMap(this::makeRequest)//携带token请求 AuthorizationServer
            .flatMap(this::adaptToNimbusResponse)//检查Http响应正确性 (看是不是200)
            .map(this::parseNimbusResponse)//封装Http响应为Token内省响应
            .map(this::castToNimbusSuccess)//检查Token内省响应正确性
            .doOnNext(response -> validate(token, response))//效验返回值 (active == true?)
            .map(this::convertClaimsSet)//解析返回值中携带的信息,封装成认证对象
            .onErrorMap(e -> !(e instanceof OAuth2IntrospectionException), this::onError);
}

NimbusReactiveOpaqueTokenIntrospector 这个类里面所有的源码我都看了一边、并没有发现有什么问题。
只是对于异常处理我有点不满, 因为如果出现了异常,我作为请求资源服务的客户端看到的响应是一片空白的, 具体错误信息都放在了Response Header 里,这个我觉得不太好。到时候把他覆盖掉给他改了。
然后源码没看出什么花来,那就打断点看看,
结果在检查Http响应正确性的方法里也就是 adaptToNimbusResponse() 中发现了不对。
这是这个方法的源码

private Mono adaptToNimbusResponse(ClientResponse responseEntity) {
 HTTPResponse response = new HTTPResponse(responseEntity.rawStatusCode());
 response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.headers().contentType().get().toString());
 if (response.getStatusCode() != HTTPResponse.SC_OK) {
 return responseEntity.bodyToFlux(DataBuffer.class)
 .map(DataBufferUtils::release)
 .then(Mono.error(new OAuth2IntrospectionException(
 "Introspection endpoint responded with " + response.getStatusCode())));
 }
 return responseEntity.bodyToMono(String.class)
 .doOnNext(response::setContent)
 .map(body -> response);
}

在断点时,请求完成了,结果判定走进了这个 if 块。也就是请求错误。不是200响应。
所以整个流程就到了这里中断。是这个响应的 HttpStatus 是400 (Bad request) 让我有点奇怪。 为什么会是400?
因为我看到后面的处理 validate() 效验返回值逻辑,正常来说请求应该是返回200,并且带上一个 active为false 的 Response body 才对啊。

AuthorizationServer CheckToken Endpoint

于是我立马就决定去看认证服务的 check_token 端点是怎么写的。
这是 SpringSecutiryOAuth2 默认的令牌检查端点的源码,   checkToken() 方法中我打了详细的注释

/**
 * Controller which decodes access tokens for clients who are not able to do so (or where opaque token values are used).
 * 
 * @author Luke Taylor
 * @author Joel D'sa
 */
@FrameworkEndpoint
public class CheckTokenEndpoint {
 
 private ResourceServerTokenServices resourceServerTokenServices;
 
 private AccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter();
 
 protected final Log logger = LogFactory.getLog(getClass());
 
 private WebResponseExceptionTranslator exceptionTranslator = new DefaultWebResponseExceptionTranslator();
 
 public CheckTokenEndpoint(ResourceServerTokenServices resourceServerTokenServices) {
 this.resourceServerTokenServices = resourceServerTokenServices;
 }
 
 /**
 * @param exceptionTranslator the exception translator to set
 */
 public void setExceptionTranslator(WebResponseExceptionTranslator exceptionTranslator) {
 this.exceptionTranslator = exceptionTranslator;
 }
 
 /**
 * @param accessTokenConverter the accessTokenConverter to set
 */
 public void setAccessTokenConverter(AccessTokenConverter accessTokenConverter) {
 this.accessTokenConverter = accessTokenConverter;
 }
 
 @RequestMapping(value = "/oauth/check_token")
 @ResponseBody
 public Map checkToken(@RequestParam("token") String value) {
 
            //读取验证的 token 字符串, 封装成OAuth2AccessToken
 OAuth2AccessToken token = resourceServerTokenServices.readAccessToken(value);
 if (token == null) {
                    // 如果找不到这个 token (非法、无效) 就直接报错
 throw new InvalidTokenException("Token was not recognised");
 }
 
 if (token.isExpired()) {
                    //token 存在,但是过期了, 也直接报错
 throw new InvalidTokenException("Token has expired");
 }
 
            //解析token表示的用户信息,提取出其认证
 OAuth2Authentication authentication = resourceServerTokenServices.loadAuthentication(token.getValue());
 
            //从认证信息中提取相应字段(过期时间、用户名之类的),封装成响应
 Map response = (Map)accessTokenConverter.convertAccessToken(token, authentication);
 
            //active=true 表示这个是一个有效的 token
 // gh-1070
 response.put("active", true); // Always true if token exists and not expired
 
 return response;
 }
 
 @ExceptionHandler(InvalidTokenException.class)
 public ResponseEntity handleException(Exception e) throws Exception {
 logger.info("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
 // This isn't an oauth resource, so we don't want to send an
 // unauthorized code here. The client has already authenticated
 // successfully with basic auth and should just
 // get back the invalid token error.
 @SuppressWarnings("serial")
 InvalidTokenException e400 = new InvalidTokenException(e.getMessage()) {
 @Override
 public int getHttpErrorCode() {
 return 400;
 }
 };
 return exceptionTranslator.translate(e400);
 }
 
}

然后看到这里我就惊了。 他这里边的逻辑显示:  token 如果发现不对,或者是 token 正确但是过期了,
就直接抛一个异常。

然后看下面的异常处理(@ExceptionHandler 注解的方法) 内部的注释,这说的是人话么

因为这不是oauth资源,因此我们不想在此处发送未经授权的代码。
”  我懂你的意思了, 知道你不想返回403状态造成资源服务器误解,
问题是你也不能够直接怼个 400 错误请求回去啊???


先不说你返回啥400, 你光返回的不是200 就很有问题了。按照道理来说,这个接口只要进来了(即客户端身份验证已经通过了), 那么出去的响应肯定得是 200 才行
因为我看了OAuth2的文档,这是 OAuth2 令牌内省的规范。

https://www.oauth.com/oauth2-servers/token-introspection-endpoint/

里面很清楚的说到了,在下面这些情况下,都不应该返回错误响应,端点仅返回无效标志

  • 请求的令牌不存在或无效
  • 令牌已过期
  • 令牌已发出给与发出此请求的客户端不同的客户端

如果说出现了令牌过期,那么返回值应该是这样子的

HTTP/1.1 200 OK

Content-Type: application/json; charset=utf-8

{
  "active": false
}

问题解决方案

还能咋解决。
重写

这是我重写令牌检查端点后的代码:

/**
 * 覆盖掉默认的令牌检查端点 {@link CheckTokenEndpoint}
 * 提供标准的 check token response
 * https://www.oauth.com/oauth2-servers/token-introspection-endpoint/
 */
@FrameworkEndpoint
class IntrospectEndpoint {
 
    @Resource(type = DefaultTokenServices.class)
    @Lazy
    private ResourceServerTokenServices resourceServerTokenServices;
 
    private AccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter();
 
    private WebResponseExceptionTranslator exceptionTranslator = new RoWebResponseExceptionTranslator();
 
 
    @PostMapping("/oauth/introspect")
    @ResponseBody
    public Map introspect(@RequestParam("token") String value) {
 
        OAuth2AccessToken token = resourceServerTokenServices.readAccessToken(value);
 
        if (token == null) {
            return Map.of("active", false, "msg", "Token was not recognised");
        }
 
        if (token.isExpired()) {
            var builder = ImmutableMap.builder();
            builder.put("active", false).put("msg", "Token has expired");
 
            if (Objects.nonNull(token.getExpiration())) {
                long exp = token.getExpiration().getTime() / 1000;
                builder.put("exp", exp);
            }
 
            return builder.build();
        }
 
        OAuth2Authentication authentication = resourceServerTokenServices.loadAuthentication(token.getValue());
 
        Map response = (Map) accessTokenConverter.convertAccessToken(token, authentication);
 
        // gh-1070
        response.put("active", true);    // Always true if token exists and not expired
 
        return response;
    }
 
 
    @ExceptionHandler(OAuth2Exception.class)
    public ResponseEntity handleException(OAuth2Exception e) throws Exception {
        return exceptionTranslator.translate(e);
    }
}

重写令牌检查端点之后, 需要在认证服务器的
AuthorizationServerEndpointsConfigurer

配置中将端点映射路径修改, 即从原来的 /oauth/check_token
映射为自定义的端点路径。覆盖掉原先的实现。

最后的疑问

为啥你这 SpringSecurityOAuth2 WebFlux 检查令牌的流程是 按照规矩
来的  ??

为啥你这 SpringSecurityOAuth2 WebMVC 的检查令牌端点 不是按照规范实现
的??

你知道你这样搞, 资源服务和认证服务 接口对不上么??? 我佛了