Dubbo + Spring Security Oauth2 如何优雅解决服务提供者无法获取当前用户(下)

1,898 阅读6分钟

Quiet 项目简介:juejin.cn/post/717122…

上篇:Dubbo + Spring Security Oauth2 如何优雅解决服务提供者无法获取当前用户(上)

前言

在上篇中我们搞明白了方法 SecurityContextHolder.getContext().getAuthentication() 获取用户信息的整个过程,并且在 Dubbo 调用的时候,服务提供者无法通过该方法获取用户信息的原因,这篇我们就解决这个问题。

切入点

既然用的是 Dubbo 发起的请求,那么是否可以在发起请求和受理请求的时候,对请求进行处理,像这种开源项目,其提供的扩展点是很多的,在 Dubbo 中我们可以通过 自定义 Filter 在服务消费者和服务提供者之间实现数据的添加和获取,其原理类似 Java SPI。

解决方案

方案一:传递用户信息

既然服务提供者无法获取当前用户信息,那我们可以在调用的时候把整个用户信息都传递过去,然后再设置到 SecurityContext 中,这样服务提供者就能获取当前用户信息了。这个方案是可以解决问题的,但是非常不好,因为每个用户的用户信息数据量是不固定的,每个用户拥有的权限、角色等信息都是不一样的,传递的数据量不可控,而且会消耗大量的网络资源。使用这种方案还需要解决序列化的问题,因为这个方案不是最佳的,这里就不展开了。

方案二:自己定义一个 key,将用户信息存到 Redis

在服务消费者发起请求的时候,自己定义一个 Redis key,将用户信息存储到 Redis,然后再将这个 key 传递给服务提供者,服务提供者再去 Redis 获取用户信息。这个方案是上篇遇到的那个面试官提出的,面试的时候没仔细想这个方案的好坏,等面试结束的时候发现这个方案还是有问题的。

首先呢,Redis 已经存有一份用户信息了,再存一份,属实没必要,而且还需要维护两份用户信息,保持两份用户信息的一致。再者,这个 Redis key 的过期时间要怎么设置?设置一个固定的时间吗?这个是不行的,因为在整个系统中,用户有一个统一的 token 过期时间,如果自定义的 Redis key 时间是固定的,就会出现 token 已经过期,但是服务提供者无法知晓当前用户已经过期,因为在 Redis key 还未过期的时候它仍然能获取用户信息,这已经破坏了系统的整体性,那么就得动态去计算这个 Redis key 的过期时间,屎山就是这么堆起来的~

方案三:直接使用 token

直接使用 token,服务消费者和服务提供者以及整个系统都共用一个用户信息,方案一和方案二的所有问题也就不存在了。在上篇中我们已经知晓如何通过一个 token 获取当前用户信息,那么我们可以从 request 中获取并直接传递 token,在服务提供者处复现获取用户信息的过程。同时,服务提供者什么时候获取用户信息也是需要考虑的,为什么?因为服务消费者调用服务提供者的接口时,服务提供者并不一定需要当前用户信息,如果在请求到达服务提供者的 Filter 时就马上去获取用户信息,设置到 SecurityContext 中,获取的用户信息对服务提供者的接口来说并不一定是有用的(可能大部分接口的业务逻辑是不需要用户信息的),那么这就会造成大量的资源浪费。

也就是说,除了解决如何使用 Filter 传递 token 之外,我们还需要解决两个问题:

  1. 如何通过 token 重新获取当前用户信息
  2. 如何实现服务提供者在需要的时候才真正去获取用户信息

实现步骤

传递 token

在 Dubbo 中如何自定义 Filter:调用拦截扩展

创建配置文件

在文件夹 src/main/java/resources/META-INF/dubbo 下创建文件名为 org.apache.dubbo.rpc.Filter 的纯文本文件,文件内容为:

token-value-consumer=com.gitee.quiet.service.dubbo.filter.consumer.AccessTokenValueFilter
token-value-provider=com.gitee.quiet.service.dubbo.filter.provider.AccessTokenValueFilter

注册这两个 Filter

dubbo:
  consumer:
    filter: token-value-consumer
  provider:
    filter: token-value-provider

定义服务消费者和服务提供者的 Filter

image.png

在服务消费者发起调用的时候获取 requset 中的 token 值

RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
String tokenValue = (String)((ServletRequestAttributes) requestAttributes)
            .getRequest()
            // 这个 key 在上篇有提到
            .getAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE);

这个地方会有个问题,如果是服务 A 调用服务 B,服务 B 调用服务 C,这时候服务 B 是服务消费者,获取的 requestAttributesnull,这时就无法获取 token 了。

分析并解决:服务 B 其实是有 token 的,只是在调用服务 C 的时候无法在 Filter 处获取而已,此时我们可以借助 ThreadLocal:

public static final ThreadLocal<String> USER_TOKEN = ThreadLocal.withInitial(() -> "");

通过 token 获取用户信息

在上篇中我们知道可以使用 token 在 tokenStore 中获取用户信息,tokenStore 是我们注入的一个 Bean,那么我们就可以从 Spring 容器中获取这个 Bean:

TokenStore tokenStore = SpringUtil.getBean(TokenStore.class);
OAuth2Authentication authentication = tokenStore.readAuthentication(tokenValue);

服务提供者如何在需要用户信息的时候才去获取

为了不影响 web 请求获取用户信息的方式,在使用 Dubbo 调用的时候,服务提供者尽可能考虑通过与 web 请求获取用户信息相同的方式获取当前用户信息,也就是通过 SecurityContextHolder.getContext().getAuthentication() 方法获取用户信息,如果实在无法实现,再考虑自定义一种能满足这两种情况下都能获取用户信息的方式(优雅永不过时~)。

image.png

在上篇中我们分析了 SecurityContextHolder,在这个类里面提供了 setContext 方法设置 SecurityContext,再看下 org.springframework.security.core.context.SecurityContext,这是一个接口,我们可以自己定义实现类,在自定义的实现类中,在调用 getAuthentication 的时候才去获取用户信息,再将这个自定义类的实例作为形参调用 setContext,就可以实现服务提供者在需要用户信息的时候才去获取用户信息:

public class QuietSecurityContext implements SecurityContext {

  private final String tokenValue;

  private final SecurityContext securityContext = SecurityContextHolder.getContext();

  public QuietSecurityContext(@NotBlank String tokenValue) {
    this.tokenValue = tokenValue;
  }

  @Override
  public Authentication getAuthentication() {
    if (securityContext.getAuthentication() != null) {
      return securityContext.getAuthentication();
    }
    TokenStore tokenStore = SpringUtil.getBean(TokenStore.class);
    OAuth2Authentication authentication = tokenStore.readAuthentication(tokenValue);
    this.setAuthentication(authentication);
    return securityContext.getAuthentication();
  }

  @Override
  public void setAuthentication(Authentication authentication) {
    securityContext.setAuthentication(authentication);
  }
}

这里用到了设计模式中的装饰器模式,所以不能说设计模式在工作中没用,只是没遇到特定的场景!

Filter 实现

com.gitee.quiet.service.dubbo.filter.consumer.AccessTokenValueFilter

@Activate(group = CommonConstants.CONSUMER)
public class AccessTokenValueFilter implements Filter {

  @Override
  public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    String tokenValue;
    if (requestAttributes != null) {
      tokenValue =
          (String)
              ((ServletRequestAttributes) requestAttributes)
                  .getRequest()
                  .getAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE);
    } else {
      tokenValue = DubboThreadLocal.USER_TOKEN.get();
    }
    if (StringUtils.isNotBlank(tokenValue)) {
      invocation.setAttachment(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, tokenValue);
    }
    return invoker.invoke(invocation);
  }
}

com.gitee.quiet.service.dubbo.filter.provider.AccessTokenValueFilter

@Activate(group = CommonConstants.PROVIDER)
public class AccessTokenValueFilter implements Filter {

  @Override
  public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
    String tokenValue = invocation.getAttachment(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE);
    if (StringUtils.isNotBlank(tokenValue)) {
      DubboThreadLocal.USER_TOKEN.set(tokenValue);
      SecurityContextHolder.setContext(new QuietSecurityContext(tokenValue));
    }
    try {
      return invoker.invoke(invocation);
    } finally {
      if (StringUtils.isNotBlank(tokenValue)) {
        SecurityContextHolder.clearContext();
        DubboThreadLocal.USER_TOKEN.remove();
      }
    }
  }
}

更详细的代码可以看 Quiet 项目中的 quiet-spring-boot-starters/quiet-service-spring-boot-starter/src/main/java/com/gitee/quiet/service/dubbo/filter

结语

至此,Dubbo + Spring Security Oauth2 解决服务提供者无法获取当前用户的方案还算优雅吧

image.png
下一篇

作为一个后端 Java 开发,为何、如何自己实现一个 Markdown 编辑器