首页 > 经验记录 > 看下源码修下 SpringSecurityOAuth2 的bug,解决令牌检查端点未实现 OAuth2 规范带来的坑: ResourceServer introspect 错误

看下源码修下 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 错误请求回去啊???
反复分析.jpg
先不说你返回啥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 的检查令牌端点 不是按照规范实现 的??
 
你知道你这样搞, 资源服务和认证服务 接口对不上么??? 我佛了
 

           


EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00