ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Sa-Token数据权限实战:从源码链路到SQL条件过滤的完整方案

Sa-Token数据权限实战:从源码链路到SQL条件过滤的完整方案 先说个很常见的场景你辛辛苦苦把Sa-Token的登录、角色、权限码全部接好了菜单能按角色显示了按钮也能按权限码隐藏了结果上线第二天普通销售居然能看见全国所有客户的订单。为什么因为按钮权限只是挡住了入口没有挡住数据。这就是权限体系里最容易被忽略的一层——数据权限也叫行级权限、数据范围。Sa-Token作为Java生态里非常受欢迎的轻量级认证鉴权框架在登录、会话、角色、权限码这一层做得足够顺手但数据权限并不是它的内置功能。这篇文章我会从源码链路讲起拆一拆StpUtil、SaSession、StpInterface和SaInterceptor这几个关键环节再给出一套可以直接抄作业的数据权限落地方案帮你看明白Sa-Token能为你提供什么原料以及如何把这些原料变成SQL里的过滤条件。1. 数据权限不是Sa-Token的活但原料它在给1.1 认证、接口权限、数据权限各管一段很多项目把这三层混在一起实际它们解决的问题完全不同。认证解决的是你是谁接口权限解决的是你能不能调这个接口而数据权限解决的是你调通了这个接口之后能看到哪些行数据。同一个客户列表接口销售总监和普通销售都能调通但总监应该看到整个大区的客户销售只能看到自己跟进的客户——这就是再典型不过的行级数据权限。这三层在技术实现上也是截然不同的路径。认证和接口权限通常发生在请求进入Controller之前走的是拦截器、过滤器、AOP这类的请求前置管线数据权限则发生在数据访问层本质上是给SQL追加过滤条件让查出来的数据集合天然受限。说得直白一点前面两层是门禁数据权限是每个房间的门锁。1.2 Sa-Token的职责边界以及这篇内容适合谁Sa-Token在Java生态里的定位是轻量级认证鉴权框架。它把登录、会话管理、踢人下线、接口限流、注解鉴权这些通用能力做得非常顺手配置极少就能跑起来。但你翻遍官方文档会发现数据权限并不是它的内置功能。这不是偷懒而是数据权限跟业务模型绑定太深——有的公司按部门树管理数据有的按组织层级有的按客户归属人还有的按业务线、按地区、按门店几乎不可能出一个通用方案。所以正确的理解是Sa-Token负责给你提供造数据权限需要的原料——稳定可靠的登录态、当前用户的角色列表、权限码列表以及一个可以随时往会话里塞业务信息的容器。你要做的是把这些原料翻译成SQL条件片段。源码层面需要重点盯住四件事StpUtil如何拿到当前用户、SaSession里角色权限存在哪、StpInterface是怎么把业务数据源接进来的、Annotation拦截器是怎么一层层把关的。这篇文章适合两类人一类是已经用上了Sa-Token、正准备做数据权限但不知道从哪下手的开发同学另一类是用了MyBatis Plus的DataPermission插件或者自定义拦截器实现过一版但想回头搞清楚底层原理的读者。我尽量用源码链路加落地代码两条线交叉着讲这样既能看门道也能直接抄作业。2. 源码链路Sa-Token怎么把当前用户这件事托住2.1 StpUtil与StpLogic静态门面背后的多端隔离设计打开Sa-Token的源码你会发现几乎所有人都在用StpUtil.xxx()但StpUtil本身是一个非常薄的门面类。它所有的静态方法内部都是同一个套路先拿到StpLogic再把调用转发给它。public class StpUtil { // 拿到当前loginType对应的StpLogic对象 public static StpLogic getStpLogic() { return SaManager.getStpLogic(); } public static boolean isLogin() { return getStpLogic().isLogin(); } public static ListString getRoleList() { return getStpLogic().getRoleList(); } public static SaSession getSession() { return getStpLogic().getSession(); } }SaManager内部维护了一个MapString, StpLogickey是loginType默认值是login。这个设计的价值在于同一个JVM里可以跑多套互相独立的登录体系——后台管理系统一套loginTypeApp端用户端一套apploginType互不干扰。做数据权限时这个特性用得上如果你的多端用户表不一样、角色体系不一样数据权限策略自然也要按loginType区分而不是写一套逻辑生搬硬套。StpLogic才是真正的逻辑实现类token的创建、校验、续期、会话读取、角色权限检查全在它这里。搞清楚这层关系以后遇到某个用户当前登录态相关的bug直接顺藤摸瓜翻StpLogic遇到存储相关的性能问题重点看SaTokenDao即可。2.2 SaTokenDao与会话存储数据到底躺在哪SaTokenDao是一个接口定义了token、会话、权限数据的基础读写操作。它的底层设计非常抽象所有数据都泛化为字符串Key加Object值的存取模型。public interface SaTokenDao { SaTokenDao set(String key, Object value, long timeout); Object get(String key); SaTokenDao update(String key, Object value); SaTokenDao delete(String key); }默认实现SaTokenDaoDefaultImpl用内存Map存数据适合单机开发调试。生产环境一般引入sa-token-redis依赖通过Spring Boot自动配置把SaTokenDao换成Redis实现所有会话、token立即可跨实例共享——这也是Sa-Token能做分布式登录的基础。这条存储链路对数据权限的意义非常直接你的角色列表、权限列表以及随手往会话里塞的部门ID用户类型等业务字段最终都序列化地存在这个存储层里。既然知道数据在哪你就知道什么时候该清缓存、什么时候该踢人下线、什么时候序列化会出问题。比如往SaSession里塞一个没有实现Serializable的自定义对象配了Redis存储后启动就能报错这种坑提前知道能省不少时间。2.3 SaSession与StpInterface角色权限的懒加载契约登录成功之后Sa-Token会为当前用户创建一个SaSession对象。这个对象里面除了tokenSignList当前会话关联的token签名列表和业务dataMap之外最关键的是持有两个集合角色列表和权限列表。源码里的实现细节会随版本调整但设计契约是稳定的会话对象持有角色与权限集合首次读取时如果发现集合为空就触发懒加载从外部数据源拉取数据并缓存进会话之后每次读取都走缓存。实现这个外部数据源的入口就是官方留给你的StpInterface接口public interface StpInterface { ListString getPermissionList(Object loginId, String loginType); ListString getRoleList(Object loginId, String loginType); }你只需要实现这个接口并注册成Spring BeanSa-Token在第一次需要角色或权限列表时会自动调用你的实现类。Component public class StpInterfaceImpl implements StpInterface { Autowired private UserRoleService userRoleService; Override public ListString getPermissionList(Object loginId, String loginType) { return userRoleService.selectPermissionCodesByUserId((Long) loginId); } Override public ListString getRoleList(Object loginId, String loginType) { return userRoleService.selectRoleCodesByUserId((Long) loginId); } }注意StpInterface返回的是字符串列表——角色编码和权限标识。Sa-Token只认字符串不认对象。所以你的角色实体、权限实体都要先映射成编码返回。这一步对数据权限来说极其关键因为角色集合是存会话加懒加载的你的数据权限切面里拿到的StpUtil.getRoleList()就是这份缓存数据可以直接拿角色编码去匹配该角色对应的数据范围配置。2.4 注解拦截链路SaInterceptor如何一层层把关Sa-Token的注解鉴权核心组件是SaInterceptor一个Spring MVC的HandlerInterceptor。集成方式很简单Configuration public class SaTokenConfigure implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor()).addPathPatterns(/**); } }注册之后每个请求进入Controller之前都会经过SaInterceptor.preHandle。它做两件事第一执行你传入的鉴权函数比如new SaInterceptor(handle - StpUtil.checkLogin())要求所有被拦截的接口都先登录第二扫描目标Handler方法上的注解——SaCheckLogin、SaCheckRole、SaCheckPermission、SaCheckSafe、SaCheckDisable、SaCheckOr——逐个校验。这些校验逻辑最终都会走到StpLogic对应的check方法读取的就是SaSession里缓存的角色和权限集合。校验失败时抛出NotLoginException、NotRoleException或NotPermissionException由全局异常处理器统一转成HTTP响应。整个链路浓缩成一句话请求 → 拦截器 → 注解 → 策略 → 会话缓存 → 放行或抛异常。这也是做数据权限时最值得借鉴的管线思想鉴权是请求级别的统一入口数据权限也应该有一个数据访问级别的统一入口而不是在几十个Mapper方法里各写各的。3. 数据权限的三种落地形态选对才是关键3.1 手动传参半小时能跑通但漏洞也在这最原始的做法是每个需要数据权限的查询方法都接收一个条件字符串参数SQL里通过${}拼接进去。ListOrderVO selectOrderList(Param(dataScope) String dataScope);SELECT * FROM t_order o WHERE o.deleted 0 ${dataScope}调用方手动拼条件String scope AND o.create_by loginId; orderMapper.selectOrderList(scope);优点就是直白小型项目一个人盯得住的话半小时就能跑通。但缺点同样明显第一调用方如果忘记拼条件传个空字符串等于全表泄露第二几十个Mapper方法每个都要多一个参数到处是重复的拼SQL逻辑第三如果是把前端参数拼进这个字符串SQL注入的后门就直接打开了。这个方案只适合接口数量极少、团队里能统一代码纪律的场景我不推荐在多人协作的中大型项目里用。3.2 MyBatis拦截器自动化程度高成本也高进阶做法是绕过业务代码在MyBatis执行SQL之前统一改写。通过Intercepts注解拦截Executor的query方法解析原始SQL的语法树识别出当前Mapper方法的数据权限配置然后自动追加WHERE条件。Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DataScopeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 1. 取出MappedStatement // 2. 解析SQL一般借助jsqlparser // 3. 依据方法上的元信息生成条件并追加 // 4. 继续执行原查询 } }这种方案对业务代码侵入性最小、改造面最小但实现成本非常高。SQL解析不是小事要区分WHERE后面有没有条件、别名要套对、GROUP BY、LIMIT、子查询、UNION各种语法边界都得覆盖jsqlparser版本不同还会踩兼容坑。只有当存量系统有上百个Mapper、没精力一个一个改的时候才值得投入做通用拦截器。新项目从零起步我更建议直接上方案三。3.3 注解加AOP切面兼顾可控与可维护的推荐方案方案三其实是方案一的工程化版本。原理一样——数据权限最终就是一条SQL条件片段——但通过自定义注解和AOP切面保证这个片段必然存在、统一生成、到处可用。设计思路是定义DataScope注解标注在Service方法或Mapper方法上声明这个方法需要数据权限定义切面在方法执行前读取StpUtil中的用户角色和部门信息计算出对应的条件片段放入ThreadLocalMapper的SQL里用${dataScope}引用这个片段方法执行完毕在finally里清空ThreadLocal防止线程复用串数据。这个方案和方案一的本质区别在于条件片段不再由每个调用方手工拼接而是切面统一生成。调用方哪怕忘了标注解SQL里还可以用默认值兜底切面本身也能统一校验当前有没有登录、有没有数据范围上下文把漏配问题提前暴露。这里顺便说一句Spring AOP的动态代理机制。如果你的Bean有接口Spring默认会用JDK动态代理如果目标类没有接口会退到CGLIB生成子类代理。Spring Boot里spring.aop.proxy-target-classtrue是默认值所以大多数场景走的是CGLIB。这与数据权限有什么关系关系很大切面能不能生效取决于目标方法是不是通过代理对象调用。如果你在同一个类里this.xxx()自调用代理就被绕过了这个坑后面我会专门展开。4. 实操从登录到SQL的数据权限完整链路下面给出一套可以直接复用的方案场景是典型的订单按部门隔离用户表t_user有dept_id订单表t_order有dept_id和create_by角色编码决定数据范围规则如下角色编码数据范围SQL条件片段admin全部数据11sales_director本部门及以下dept_id IN (子部门集合)sales_manager本部门dept_id 当前部门sales仅本人create_by 当前用户4.1 登录时把部门快照塞进会话不要在切面里每次查数据库拿用户部门登录时就把部门信息塞进SaSession这一步是关键的性能优化。// 登录校验完成之后 SaSession session StpUtil.getSession(); session.set(deptId, user.getDeptId()); session.set(userId, user.getId());StpUtil.getSession()每次从当前token解析出会话对象所以塞进去的deptId在后续任何请求里都能用StpUtil.getSession().get(deptId)取到。注意这个值是登录时的快照如果用户调岗了得等他重新登录才会刷新业务上要提前想清楚这个口径能不能接受。不能接受的话就要设计部门变更后踢人下线的联动逻辑。4.2 定义数据范围注解与上下文自定义注解让方法声明自己属于哪张表的哪个维度Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DataScope { // 部门字段的表别名复杂SQL里可能有多个表必须指定 String deptAlias() default d; // 创建人字段的表别名 String userAlias() default u; }ThreadLocal上下文负责在同一个线程内传递条件片段public class DataScopeContext { private static final ThreadLocalString HOLDER new ThreadLocal(); public static void set(String sqlCondition) { HOLDER.set(sqlCondition); } public static String get() { String condition HOLDER.get(); // 兜底没有上下文时宁可查不到也不能全表查询 return condition null ? AND 10 : condition; } public static void clear() { HOLDER.remove(); } }ThreadLocal.remove()一定要放在finally里这是老生常谈但我在生产环境确实见过因为忘记清理导致上一单客户的数据串到下一单的事故。为什么用ThreadLocal因为一次请求的主线程里可能串行调用多个Mapper把条件放在线程变量里它们都能读到方法结束再清掉正好契合Tomcat线程池的复用模型。4.3 切面把角色翻译成条件片段Aspect Component public class DataScopeAspect { Around(within(dataScope) || annotation(dataScope)) public Object doAround(ProceedingJoinPoint pjp, DataScope dataScope) throws Throwable { String condition buildCondition(dataScope); DataScopeContext.set(condition); try { return pjp.proceed(); } finally { DataScopeContext.clear(); } } private String buildCondition(DataScope dataScope) { long loginId StpUtil.getLoginIdAsLong(); ListString roles StpUtil.getRoleList(); if (roles.contains(admin)) { return AND 11; } Long deptId (Long) StpUtil.getSession().get(deptId); if (roles.contains(sales_director)) { ListLong deptIds deptService.selectChildDeptIds(deptId); return AND dataScope.deptAlias() .dept_id IN ( join(deptIds) ); } if (roles.contains(sales_manager)) { return AND dataScope.deptAlias() .dept_id deptId; } // 默认仅本人 return AND dataScope.userAlias() .create_by loginId; } }几个细节展开说。第一为什么用within(dataScope) || annotation(dataScope)within匹配类上有注解的情况annotation匹配方法上有注解的情况。一个Service类上标了DataScope它内部的查询方法都算如果某个方法想覆盖类级别策略就在方法上单独标。两种都匹配使用起来够灵活。第二join(deptIds)生成的是形如1001,1002,1003的字符串拼进SQL就是IN (1001,1002,1003)。部门ID来自服务端部门树查询结果不是前端传参所以拼接可控。切面里禁止出现任何把前端参数拼进SQL的写法这是底线。第三条件统一以AND开头SQL模板里只要写${dataScope}就能无缝接在其他条件后面不需要每个case自己处理空格。第四如果当前用户压根没登录StpUtil.getLoginIdAsLong()会抛出NotLoginException。这是期望行为数据权限天然要求先登录不登录连数据范围都没法定义让全局异常处理器统一返回未登录提示即可。4.4 Mapper与SQL里的引用方式select idselectOrderPage resultTypecom.demo.OrderVO SELECT o.id, o.order_no, o.create_by, d.dept_name FROM t_order o LEFT JOIN t_dept d ON o.dept_id d.dept_id WHERE o.deleted 0 ${dataScope} ORDER BY o.create_time DESC /select注意${dataScope}用的是${}而不是#{}。#{}是预编译占位符会被当作参数值${}是直接字符串替换在SQL解析阶段就会展开成AND d.dept_id IN (1001,1002)。这里用${}是故意的因为条件片段是切面生成的受控字符串。同时要保证SQL模板里${dataScope}前面至少有一个其他WHERE条件比如上例的o.deleted 0。如果模板写成WHERE ${dataScope}而dataScope以AND开头语法就错了。稳妥的写法是模板里固定写WHERE 11 ${dataScope}这样任何条件下都不会出语法问题。4.5 效果验证与常见细节用sales角色登录访问订单分页接口MyBatis实际打印出的SQL应该是SELECT o.id, o.order_no, o.create_by, d.dept_name FROM t_order o LEFT JOIN t_dept d ON o.dept_id d.dept_id WHERE o.deleted 0 AND d.dept_id 1001 ORDER BY o.create_time DESC如果是sales_director就变成... AND d.dept_id IN (1001,1002,1003)如果是adminAND 11对结果无过滤语义上是管理员全量可见。整条链路串起来登录塞快照 →StpUtil取角色 → 切面算范围 → ThreadLocal传条件 → Mapper拼SQL → finally清上下文。这里有个容易忽略的细节DataScope切面作用在Service层还是Mapper层我建议作用在Service层原因有两点。一是Service层是业务入口一个方法内部可能调用多个Mapper放在Service上可以一次性覆盖该业务方法涉及的所有查询二是Mapper层是MyBatis的接口代理Spring AOP对Mapper的代理行为在MyBatis-Spring的旧版本里配合annotation有时会踩到代理对象不是同一个的坑没必要把风险揽到自己身上。5. 踩坑实录与排查清单5.1 异步线程丢上下文数据权限秒变全表泄露数据权限条件放在ThreadLocal里意味着它天然只属于当前线程。一旦出现CompletableFuture.supplyAsync、Async、或者消息队列消费线程子线程里读取DataScopeContext.get()会拿到nullSQL里的${dataScope}就变成了空字符串等于全表查询——这是数据权限最严重的事故形态。我踩过一次报表导出功能用了异步任务导出来的Excel直接包含了全公司数据。排查时日志很诡异主线程条件是dept_id 1001异步线程里${dataScope}直接消失。从那以后定了两条规矩一是异步任务里的数据查询不允许隐式依赖线程变量要么在进入异步前把条件作为参数显式传递要么用TransmittableThreadLocal在线程池之间传递上下文二是涉及数据权限的MapperSQL里强制用默认值兜底也就是DataScopeContext.get()里那段取不到就AND 10的逻辑——宁可不返回数据也不能把数据漏出去。5.2 角色改了权限没变问题出在缓存用户从sales调成sales_director但没让他重新登录你会发现他的数据范围还是老一套。因为Sa-Token把角色列表缓存在SaSession里改用户角色表并不会自动触发会话更新。常见做法是管理后台编辑用户角色提交之后调用一次StpUtil.kickout(loginId)把该用户踢下线要求重新登录如果业务上不能接受强制下线就提供一个刷新权限缓存的接口主动清掉目标用户的会话或角色缓存。另外提醒一句如果是多个管理员并发维护用户角色user_role表的更新建议加版本号用乐观锁防止两个管理员同时改角色互相覆盖——数据权限的源头数据一旦脏了会话里所有缓存都是脏的。5.3 同类内自调用导致切面失效Spring AOP的代理机制有个经典盲区同一个类里的方法互相调用走的是this.xxx()而非代理对象切面根本不触发。我见过一个很隐蔽的bugService类打上了DataScope另一个普通方法里this.selectOrderList()数据权限全被绕过。规避办法是治本的切面注解优先放在方法上而不是类上类内部自调用时不要用this注入自己的代理再调用或者干脆拆一个独立的查询Service出来让跨类调用自然经过代理。这个问题的根源是JDK动态代理和CGLIB动态代理都只能拦截从外部进入代理的调用自调用绕过了代理层这跟权限框架本身没有关系排查时别把锅甩给Sa-Token。5.4 部门树查询别塞进业务SQL每次请求都要查一次部门子树如果部门表上万行递归查询一多接口就慢。两种解法其一部门层级在管理后台变更频率极低给部门树加本地缓存或Redis缓存变更时主动刷新其二给部门表加ancestors字段用一条LIKE查询替代递归在切面里把结果算好不要让它进入每一条业务SQL的运行时路径。我见过有的团队把部门子查询直接写进数据权限SQL里导致每条订单查询都嵌套一次递归子查询数据量一起来接口直接崩掉。记住一条原则数据权限条件应该在切面阶段算成固定值集合而不是在每条业务SQL里动态计算。5.5 兜底策略宁可查不到不可全放行最后聊一个设计取舍。数据权限的兜底策略业界一直有全放行和全拒绝两种声音。全放行更顺滑但代价是任何一处配置遗漏都会静默泄露数据全拒绝更稳妥配置遗漏的表现是用户看不到数据顶多是工单而数据泄露是事故。我的选择很明确生产环境默认AND 10配合日志告警。一旦出现用户反馈看不到数据排查发现是数据权限上下文没设置的问题说明代码入口有遗漏修掉就好。这样做的底气是把数据权限当成数据库防火墙宁可误杀不可漏过。顺带一提${dataScope}拼接虽然由切面受控生成但代码评审时还是要重点检查一条数据权限条件里出现的所有值必须来自服务端数据库查询或登录会话任何从HTTP请求参数里流入的内容一旦进了这个片段就是实打实的SQL注入漏洞。这条红线要写进团队规范里。最后再分享一点个人体会。数据权限这个功能往浅了做是一段SQL片段往深了做是整套系统的数据边界设计。Sa-Token给的原料确实够用但它不会替你回答谁的部门算本部门离职员工的订单归谁跨部门协同时数据怎么共享这些问题。我见过太多项目把数据权限做成一刀切的部门隔离业务上一出现协作场景就只能让开发写绕过逻辑越写越烂。所以动手之前先跟业务方把数据范围的口径定清楚再落技术方案。技术方案永远有得抄业务边界一旦搞错后面返工的成本是呈几何级数上升的。
返回列表