[Sa-Token]Spring Cloud Gateway中支持子服务的@SaIgnore注解

需求场景见#415

由于网关网关与子服务之间没有直接关系, 因此必须通过redis等中间件进行交互.

仅实现了根据请求路径(path)和请求方式(method)进行鉴权, 如果映射(见org.springframework.web.bind.annotation.RequestMapping)中存在params / headers / consumes / produces等匹配方式则行不通

思路

  1. 子服务启动时

    1. 通过bean requestMappingHandlerMapping遍历接口

    2. 查所有含SaIgnore注解的接口(方法或类上拥有), 作为includeList

    3. 如includeList中的接口含有通配符, 则将剩余接口放到excludeList

      原因: 通配符会导致优先级处理问题, 如以下几对路径会造成匹配错误(实际访问路径为/abc/abc时均为接口B优先, 但是如果接口A上有SaIgnore则会被放行), 而这部分场景不常见, 且数据量不大, 如果对excludeList做过滤意义也不大.

      接口A 接口B
      /*/abc /abc/abc
      /** /abc/abc
      /a?c/abc /abc/abc
      /{path}/abc /abc/abc
    4. 生成uuid, 作为version存储, 用于网关判断是否需要更新

    5. includeList和excludeList大致结构如下:

      [{
          methods: ['GET', 'POST'],
          patterns: ['/abc/def', '/abc/ghi'],
      }, {
          methods: ['GET'],
          patterns: ['/abc/jkl']
      }]
      
    6. 将includeList, excludeList, version存储到缓存中, 并以appId(spring.application.name)作为前缀区分

  2. 网关收到请求时

    1. 收到请求, 匹配目标子服务的appId(spring.application.name), 根据version判断是否需要更新excludeList和includeList
    2. 匹配路由, 通过请求方式请求路径, 判断excludeList中是否存在, 如果存在, 则进行鉴权, 否则直接放行

具体实现

如果存在动态路由, 如Controller中有RefreshScope注解, SaTokenSaIgnoreCollectRunner中的代码需要在刷新配置后再次调用.

1. 子服务

SaIgnoreCollector.java

package kim.nzxy.ly.common.sa.ignore;

import cn.dev33.satoken.router.SaRouter;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.bind.annotation.RequestMethod;

import java.util.List;
import java.util.Objects;
import java.util.Set;


@Data
@AllArgsConstructor
@NoArgsConstructor
public class SaIgnoreCollector {
    /**
     * 请求方式
     */
    private Set<RequestMethod> methods;
    /**
     * 路由规则
     */
    private List<String> patterns;
}

SaIgnoreGatewayUtil.java

package kim.nzxy.ly.common.sa.ignore;

/**
 * SaIgnore 网关适配管理
 * @author ly-chn
 */
public class SaIgnoreGatewayUtil {
    private static final String KEY_PREFIX = "sa-token:ignore:";
    public static String includeKey(String appId) {
        return KEY_PREFIX + "includes:" + appId;
    }

    public static String excludeKey(String appId) {
        return KEY_PREFIX +"excludes:"+appId;
    }

    public static String versionKey(String appId) {
        return KEY_PREFIX + "version:" + appId;
    }
}

SaTokenSaIgnoreCollectRunner.java

package kim.nzxy.ly.common.runner;

import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.annotation.SaIgnore;
import kim.nzxy.ly.common.util.SpringContextUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
import org.springframework.web.util.pattern.PathPattern;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

/**
 * @author ly-chn
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class SaTokenSaIgnoreCollectRunner implements CommandLineRunner {

    @Override
    public void run(String... args) {
        RequestMappingInfoHandlerMapping mapping = SpringContextUtil.getBean("requestMappingHandlerMapping", RequestMappingInfoHandlerMapping.class);
        Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();
        List<SaIgnoreCollector> includeList = new ArrayList<>();
        handlerMethods.forEach((info, method) -> {
            if (hasIgnored(method)) {
                // noinspection DataFlowIssue
                includeList.add(new SaIgnoreCollector(info.getMethodsCondition().getMethods(),
                        info.getPathPatternsCondition().getPatterns().stream()
                                .map(PathPattern::getPatternString).collect(Collectors.toList())));
            }
        });
        String appId = SpringContextUtil.getId();
        List<SaIgnoreCollector> excludeList = new ArrayList<>();
        if (includeList.stream().anyMatch(this::hasAntStylePath)) {
            handlerMethods.forEach((info, method) -> {
                if (!method.hasMethodAnnotation(SaIgnore.class)) {
                    // noinspection DataFlowIssue
                    excludeList.add(new SaIgnoreCollector(info.getMethodsCondition().getMethods(),
                            info.getPathPatternsCondition().getPatterns().stream()
                                    .map(PathPattern::getPatternString).collect(Collectors.toList())));
                }
            });
        }
        SaManager.getSaTokenDao().setObject(SaIgnoreGatewayUtil.excludeKey(appId), excludeList, -1);
        SaManager.getSaTokenDao().setObject(SaIgnoreGatewayUtil.includeKey(appId), includeList, -1);
        SaManager.getSaTokenDao().setObject(SaIgnoreGatewayUtil.versionKey(appId), UUID.randomUUID().toString(), -1);

    }

    private boolean hasAntStylePath(SaIgnoreCollector collector) {
        String s = collector.getPatterns().toString();
        return s.contains("?") || s.contains("*");
    }

    private boolean hasIgnored(HandlerMethod method) {
        return method.hasMethodAnnotation(SaIgnore.class)|| AnnotatedElementUtils.isAnnotated(method.getBeanType(), SaIgnore.class);
    }
}

SpringContextUtil.java

package kim.nzxy.ly.common.util;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

/**
 * @author ly-chn
 */
@Component
public class SpringContextUtil implements ApplicationContextAware {
    private static ApplicationContext context;
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        context = applicationContext;
    }

    public static <T> T getBean(Class<T> requiredType) {
        return context.getBean(requiredType);
    }

    public static <T> T getBean(String name, Class<T> requiredType) {
        return context.getBean(name, requiredType);
    }

    public static String getId() {
        return context.getId();
    }
}

2. 网关中

我们的逻辑是根据服务id+请求方式+请求路径来判断, 那么sa-token文档中的SaReactorFilter就不能用了, 需要自己写一个了

SaIgnoreCollectorCache.java

package kim.nzxy.ly.common.sa.ignore;

import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.dao.SaTokenDao;
import cn.dev33.satoken.util.SaFoxUtil;
import kim.nzxy.ly.common.runner.SaIgnoreCollector;
import kim.nzxy.ly.common.runner.SaIgnoreGatewayUtil;

import java.util.*;

public class SaIgnoreCollectorCache {

    private static final Map<String, List<SaIgnoreCollector>> INCLUDE_CACHE = new HashMap<>(16);
    private static final Map<String, List<SaIgnoreCollector>> EXCLUDE_CACHE = new HashMap<>(16);
    private static final Map<String, String> VERSION_CACHE = new HashMap<>(16);
    private static final List<SaIgnoreCollector> EMPTY = Collections.emptyList();

    public static List<SaIgnoreCollector> getIncludeList(String appId) {
        refresh(appId);
        return Optional.ofNullable(INCLUDE_CACHE.get(appId)).orElse(EMPTY);
    }

    public static List<SaIgnoreCollector> getExcludeList(String appId) {
        refresh(appId);
        return Optional.ofNullable(INCLUDE_CACHE.get(appId)).orElse(EMPTY);
    }

    @SuppressWarnings("unchecked")
    private static void refresh(String  appId) {
        SaTokenDao dao = SaManager.getSaTokenDao();
        String version = dao.get(SaIgnoreGatewayUtil.versionKey(appId));
        // 取不到版本, 说明没更新
        if (SaFoxUtil.isEmpty(version)) {
            INCLUDE_CACHE.remove(appId);
            EXCLUDE_CACHE.remove(appId);
            return;
        }
        if (!Objects.equals(VERSION_CACHE.get(appId), version)) {
            VERSION_CACHE.put(appId, version);
            INCLUDE_CACHE.put(appId, (List<SaIgnoreCollector>) dao.getObject(SaIgnoreGatewayUtil.includeKey(appId)));
            EXCLUDE_CACHE.put(appId, (List<SaIgnoreCollector>) dao.getObject(SaIgnoreGatewayUtil.excludeKey(appId)));
        }
    }
}

SaIgnoreFilter.java

package kim.nzxy.gateway.filter;

import cn.dev33.satoken.stp.StpUtil;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.core.Ordered;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import kim.nzxy.ly.common.runner.SaIgnoreCollector;

import java.net.URI;
import java.util.List;
import java.util.Optional;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR;

/**
 * @author Liaoliao
 */
@Component
public class SaIgnoreFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 登录了就放行
        if (StpUtil.isLogin()) {
            return chain.filter(exchange);
        }
        Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
        Optional<String> appId = Optional.ofNullable(route).map(Route::getUri).map(URI::getHost);
        if (appId.isPresent()) {
            String path = exchange.getRequest().getPath().value();
            String method = exchange.getRequest().getMethodValue();
            RequestMethod requestMethod = RequestMethod.valueOf(method);
            List<SaIgnoreCollector> excludeList = SaIgnoreCollectorCache.getExcludeList("application");
            if (!excludeList.isEmpty()) {
                boolean unIgnore = excludeList.stream().anyMatch(it -> it.match(requestMethod, path));
                if (unIgnore) {
                    return needLogin(exchange);
                }
            }
            List<SaIgnoreCollector> includeList = SaIgnoreCollectorCache.getIncludeList("application");
            if (includeList.isEmpty()) {
                return needLogin(exchange);
            }
            return includeList.stream().anyMatch(it -> it.match(requestMethod, path));
        } else {
            // 非lb, 自行处理
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return -100;
    }

    public Mono<Void> needLogin(ServerWebExchange exchange) {
        ServerHttpResponse response = exchange.getResponse();
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        // todo: 构建自己的json
        return response.writeWith(Mono.just(response.bufferFactory().wrap("{msg: xxx, code: xxx}".getBytes())));
    }
}

总结

非sa-token内部支持, 且对SaIgnore支持不全, 如确实有SaIgnore需求, 建议网关不执行鉴权策略, 由子服务自行鉴权

微服务体量的服务, 鉴权策略等信息应当尽可能与编码解耦, 但是开发团队规模不够大, 维护投入少的情况下, 还是推荐高耦合的编码方式, 以降低开发心智负担.

你可能感兴趣的:(java,spring,cloud,springboot)