[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需求, 建议网关不执行鉴权策略, 由子服务自行鉴权

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

### 集成 sa-tokenSpring Cloud 项目中的方法 为了实现分布式会话管理和权限控制,可以按照以下方式配置 `sa-token` 并将其集成到 Spring Cloud 项目中。 #### 添加依赖 在项目的 `pom.xml` 文件中引入必要的依赖项。以下是推荐的依赖版本: ```xml <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.38.0</version> </dependency> ``` 此依赖提供了与 Spring Boot 的无缝集成支持[^2]。 --- #### 配置 sa-token 属性 在 `application.yml` 或 `application.properties` 文件中定义 sa-token 的基础属性。例如: ```yaml sa-token: token-name: satoken # 自定义 Token 名称,默认为 "satoken" timeout: 2592000 # token有效期,单位s,默认30天 is-concurrent: true # 是否开启允许多处登录 kickout-rule: LRU # 被踢下线策略:LRU(最近最少使用) / FIFO(先进先出) max-kickout: 5 # 同一账号最大并发数 (is-concurrent=true时有效),默认不限制 ``` 这些配置能够满足大多数场景下的需求,并允许开发者自定义行为[^1]。 --- #### 初始化 StpUtil 工具类 通过静态工具类 `StpUtil` 可以快速操作用户的登录状态和令牌信息。例如,在用户成功登录后调用以下代码生成并返回 Token: ```java import cn.dev33.satoken.stp.StpUtil; public String login(String username, String password) { // 假设验证逻辑已通过 StpUtil.login(username); // 登录指定账户id return StpUtil.getTokenValue(); // 获取当前账户的Token值 } ``` 上述代码片段展示了如何创建一个新的会话以及获取对应的 Token 值。 --- #### 分布式环境的支持 对于多服务架构而言,确保各微服务间共享同一份 Redis 数据存储至关重要。因此需额外增加如下配置来启用基于 Redis 的持久化机制: ```properties spring.redis.host=localhost spring.redis.port=6379 sa-token.is-share=true # 开启跨应用共享功能 sa-token.cache-type=redis # 设置缓存类型为Redis模式 ``` 当设置好以上参数之后,所有的节点都会同步访问同一个 Redis 实例来进行数据交换。 --- #### 定义拦截器进行全局校验 可以通过 AOP 方面或者 WebFlux Filter 对请求路径做统一处理,从而达到自动检验身份的效果。下面是一个简单的例子展示如何利用注解形式完成资源保护工作: ```java @RestController @RequestMapping("/api/user") @SaCheckLogin // 表明该接口需要登录才能访问 public class UserController { @GetMapping("/{id}") public ResponseEntity<UserDto> getUserById(@PathVariable Long id){ UserDto user = userService.findById(id); return new ResponseEntity<>(user , HttpStatus.OK ); } } ``` 这里我们运用了 `@SaCheckLogin` 注解标记整个控制器都需要经过认证才可以被调用。 --- #### 测试学习建议 如果实际开发过程中碰到困难,则可以直接参照官方提供的 demo 示例工程 `/sa-token-demo/sa-token-demo-webflux` 进行调试研究。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值