看下源码修下 SpringSecurityOAuth2 的bug,解决令牌检查端点未实现 OAuth2 规范带来的坑: ResourceServer introspect 错误
最近在自己搭一个使用 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<OAuth2AuthenticatedPrincipal> 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<HTTPResponse> 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<OAuth2Exception> exceptionTranslator = new DefaultWebResponseExceptionTranslator(); public CheckTokenEndpoint(ResourceServerTokenServices resourceServerTokenServices) { this.resourceServerTokenServices = resourceServerTokenServices; } /** * @param exceptionTranslator the exception translator to set */ public void setExceptionTranslator(WebResponseExceptionTranslator<OAuth2Exception> 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<String, ?> 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<String, Object> response = (Map<String, Object>)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<OAuth2Exception> 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<OAuth2Exception> exceptionTranslator = new RoWebResponseExceptionTranslator(); @PostMapping("/oauth/introspect") @ResponseBody public Map<String, Object> 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.<String, Object>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<String, Object> response = (Map<String, Object>) 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<OAuth2Exception> handleException(OAuth2Exception e) throws Exception { return exceptionTranslator.translate(e); } }
重写令牌检查端点之后, 需要在认证服务器的 AuthorizationServerEndpointsConfigurer 配置中将端点映射路径修改, 即从原来的 /oauth/check_token
映射为自定义的端点路径。覆盖掉原先的实现。
最后的疑问
为啥你这 SpringSecurityOAuth2 WebFlux 检查令牌的流程是 按照规矩 来的 ??
为啥你这 SpringSecurityOAuth2 WebMVC 的检查令牌端点 不是按照规范实现 的??
你知道你这样搞, 资源服务和认证服务 接口对不上么??? 我佛了
ResourceServerTokenServices 注入报错 找不到对应的类 请问要怎么才能注入呢