Pig 中 Url 的访问大致分为两种,一种是由 Gateway 网关从外网路由进来,一种是内网通过 Feign 进行内部服务调用。那我们结合 Security 就存在以下几种应用场景:

  1. 外部从 Gateway 访问,需要鉴权(eg.CURD 操作)。这种是最常使用的,用户登录后正常访问接口,不需要我们做什么处理(可能有的接口需要加权限字段)。
  2. 外部从 Gateway 访问,不需要鉴权(eg.短信验证码)。需要我们将 uri 加入到 security.oauth2.client.ignore-urls 配置中,可以不需要鉴权访问
  3. 内部服务间用 Feign 访问,不需要鉴权(eg.Auth 查询用户信息)。也是需要我们将 uri 加入到 security.oauth2.client.ignore-urls 配置中,那与第二种的区别就是这种情况下大多数都是服务可以请求另一个服务的所有数据,不受约束,那我们如果仅仅只配置 ignore-url 的话,外部所有人都可以通过 url 请求到我们内部的链接,安全达不到保障。

鉴于上述第三种情况,我们配置了 ignore-url 和 Feign,此时该接口不需要鉴权,服务内部通过 Feign 访问,服务外部通过 url 也可以访问,所以 Pig 中,加入了一种@RequestHeader(SecurityConstants.FROM)的处理方式。即在接口方法中,对头部进行判断,只有请求带上相应的 Header 参数时,才允许通过访问,否则抛出异常。那这时候其实我们在外网通过 Gateway 访问的时候,也可以手动带上这个 Header 参数,来达到这个目的。所以我们便在 Gateway 中设置了一个 GlobalFilter 过滤器,对来自外网通过 Gateway 手动拼接的参数进行过滤与清洗,具体代码见 com.pig4cloud.pig.gateway.filter.PigRequestGlobalFilter:

	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		// 1. 清洗请求头中 from 参数
		ServerHttpRequest request = exchange.getRequest().mutate()
				.headers(httpHeaders -> httpHeaders.remove(SecurityConstants.FROM)).build();

		// 2. 重写 StripPrefix
		addOriginalRequestUrl(exchange, request.getURI());
		String rawPath = request.getURI().getRawPath();
		String newPath = "/" + Arrays.stream(StringUtils.tokenizeToStringArray(rawPath, "/")).skip(1L)
				.collect(Collectors.joining("/"));
		ServerHttpRequest newRequest = request.mutate().path(newPath).build();
		exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newRequest.getURI());

		return chain.filter(exchange.mutate().request(newRequest.mutate().build()).build());
	}

Inner 的处理流程

统一的 ignore-url 处理

首先我们来看看这个注解的代码

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Inner {
   /**
    * 是否 AOP 统一处理 (可以理解为是否仅允许 Feign 之间调用)
    *
    * @return false, true
    */
   boolean value() default true;

   /**
    * 需要特殊判空的字段 (预留)
    *
    * @return {}
    */
   String[] field() default {};
}

首先,在我们项目加载阶段,我们获取有 Inner 注解的类和方法,然后获取我们配置的 uri,经过正则替换后面的可变参数为*,然后将此 uri 加入到 ignore-url 中。此时我们就能达到所有 Inner 配置的方法/类上的接口地址,都统一在项目加载阶段自动帮我们加到 ignore-url 中,不需要我们手动配置,免去了很多开发工作,同时也能避免我们忘记配置,而浪费开发时间。核心代码如下:

@Slf4j
@Configuration
@ConditionalOnExpression("!'${security.oauth2.client.ignore-urls}'.isEmpty()")
@ConfigurationProperties(prefix = "security.oauth2.client")
public class PermitAllUrlProperties implements InitializingBean {
   private static final Pattern PATTERN = Pattern.compile("\\{(.*?)\\}");
   @Autowired
   private WebApplicationContext applicationContext;
   @Getter
   @Setter
   private List<String> ignoreUrls = new ArrayList<>();
   @Override
   public void afterPropertiesSet() {
      RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
      Map<RequestMappingInfo, HandlerMethod> map = mapping.getHandlerMethods();
      map.keySet().forEach(info -> {
         HandlerMethod handlerMethod = map.get(info);
         // 获取方法上边的注解 替代 path variable 为 *
         Inner method = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Inner.class);
         Optional.ofNullable(method)
               .ifPresent(inner -> info.getPatternsCondition().getPatterns()
                     .forEach(url -> ignoreUrls.add(ReUtil.replaceAll(url, PATTERN, StringPool.ASTERISK))));
         // 获取类上边的注解,替代 path variable 为 *
         Inner controller = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Inner.class);
         Optional.ofNullable(controller)
               .ifPresent(inner -> info.getPatternsCondition().getPatterns()
                     .forEach(url -> ignoreUrls.add(ReUtil.replaceAll(url, PATTERN, StringPool.ASTERISK))));
      });
   }
}

统一的安全性处理

那上面讲到的,如果我们不希望这个 url 可以直接被外网调用,仅能在 Feign 服务中调用,该如何统一处理呢?

我们使用一个 Spring-AOP,在对所有 Inner 注解的方法做一个环绕增强的切点,进行统一的处理。在上面我们提到的 Inner 的 value 参数,当该参数为 true 时,我们对方法的入参进行判断,仅当符合我们定制的入参规则时(Pigx 这里是用的@RequestHeader(SecurityConstants.FROM)SecurityConstants.FROM_IN做比较),我们对它进行放行,不符合时,抛出异常;当 value 为 false 时,咱不做任何处理,此时 Inner 仅起到了一个 ignore-url 的作用。

@Slf4j
@Aspect
@AllArgsConstructor
public class PigSecurityInnerAspect {
   private final HttpServletRequest request;

   @SneakyThrows
   @Around("@annotation(inner)")
   public Object around(ProceedingJoinPoint point, Inner inner) {
      String header = request.getHeader(SecurityConstants.FROM);
      if (inner.value() && !StrUtil.equals(SecurityConstants.FROM_IN, header)) {
         log.warn("访问接口 {} 没有权限", point.getSignature().getName());
         throw new AccessDeniedException("Access is denied");
      }
      return point.proceed();
   }
}

通过这两步呢,我们首先是在加载时通过找到 Inner 注解,将相应的 uri 加入到 ignore-url 中,达到自动化配置的目的;之后我们又使用切面对 Inner 的方法进行环绕处理,达到安全控制。对比之前的处理方式,现在我们使用一个@Inner注解,就能很快的满足上面说的两种场景,大大节省了我们的开发时间。

合理的使用@Inner 注解

上面提到的两种应用场景,在我们的代码中,其实都是可以使用 Inner 注解的,下面结合 Feign 做一个简单的示例,示例场景就是我们的用户密码登录中的一环:

  1. 在接口上使用@Inner注解,使得 url 无需鉴权
    /**
     * 获取指定用户全部信息
     *
     * @return 用户信息
     */
    @Inner
    @GetMapping("/info/{username}")
    public R info(@PathVariable String username) {
       SysUser user = userService.getOne(Wrappers.<SysUser>query()
             .lambda().eq(SysUser::getUsername, username));
       if (user == null) {
          return R.failed(null, String.format("用户信息为空 %s", username));
       }
       return R.ok(userService.findUserInfo(user));
    }
  1. 编写 Feign 接口
@FeignClient(contextId = "remoteUserService", value = ServiceNameConstants.UMPS_SERVICE)
public interface RemoteUserService {
    /**
     * 通过用户名查询用户、角色信息
     *
     * @param username 用户名
     * @param from     调用标志
     * @return R
     */
    @GetMapping("/user/info/{username}")
    R<UserInfo> info(@PathVariable("username") String username
          , @RequestHeader(SecurityConstants.FROM) String from);
}
  1. Feign-Client 中调用接口,带上SecurityConstants.FROM_IN参数为内部识别
    /**
     * 用户密码登录
     *
     * @param username 用户名
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    @SneakyThrows
    public UserDetails loadUserByUsername(String username) {
       Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS);
       if (cache != null && cache.get(username) != null) {
          return (PigxUser) cache.get(username).get();
       }
       R<UserInfo> result = remoteUserService.info(username, SecurityConstants.FROM_IN);
       UserDetails userDetails = getUserDetails(result);
       cache.put(username, userDetails);
       return userDetails;
    }

现在”/info/” 这个 uri 从网关外部我们访问是报错的(一般来说服务都是走网关暴露接口),而 Feign 内部带上参数是可以正常访问的。大功告成喝杯啤酒。

♥️ 获取支持

遇到问题?

如果您在使用过程中遇到任何问题、有功能建议或需求,请点击此卡片前往 Gitee 仓库提交 Issue。