
前言
-
介绍下使用背景
-
本人近10年一直从事金融(贷款系统)和互联网研发管理工作,服务的客户(主要是银行甲方爸爸)以及目前公司自营业务,都会使用到管理系统(运营管理端),认证和授权是无法绕开的话题。
-
说来也巧,我刚开始从事研发工作的时候,参与过一家中大型公司mis系统权限子系统的研发工作,为后来权限系统的设计和开发打下了良好的基础。
-
在前公司负责整个贷款系统的研发工作中,最开始我们是完全自研权限相关功能,主要经历了几个版本的迭代:
1. 菜单级权限管控
主要使用了 菜单表、用户表、角色表以及这3者关系。
用户登录以后,查询该用户角色下所有的菜单列表,并按照菜单的树形结构返给给前端,前端负责展示功能。
2. 菜单+按钮+数据权限管控
在第1代基础上,扩展了按钮权限和数据权限管控。
- 按钮权限,主要是在菜单管理功能中,配置当前页面所有按钮
- 给角色分配权限时,每个菜单勾选按钮
- 用户进入到页面(功能)时,后端返回用户对应角色下所有按钮,没有权限的按钮前端控制隐藏
- 数据权限主要是使用MyBatis的插件机制,对每个功能以及功能下的子页面配置相关的SQL(增加查询条件)
3. 菜单+按钮+接口+数据权限管控
上面第2代方案,虽然比第1代增加了按钮和数据权限的管控,但是黑客很容易绕开管控机制,直接嗅探和访问相关的API接口,存在很大的安全风险。
在实施江西某银行贷款项目时,甲方爸爸对系统安全要求很高,尽管我们一再解释,我们系统完全是局域网内管理系统,与互联网网络环境完全隔离(业务端应用如H5或者APP由其他厂商或者系统实现,我们系统只提供指定的API接口服务),但是还是坚持要求我们系统按照漏扫结果整改,其中最大的问题就是API访问权限(越权交易)。
在此基础上,我们实现的方案是,增加菜单资源表,在每个菜单中填写需要访问的API列表,用户发起请求,系统对本次请求的 URI 进行验证(URI 与 菜单的API 相匹配),经过复测,问题得到解决。
-
-
使用 Sa-Token 解决的问题
完全自研权限管理,研发成本还是比较高的,初步估算,我们3次迭代,耗费的成本以百万计,而且系统之间的耦合很紧密。
离开金融行业,加入现在的互联网公司(中小型规模)以后,在主导开发公司的权益业务中台时,也面临管理系统的建设和技术选型,与部门技术骨干经过多轮讨论,最后采用的方案是购买一套商业版开发平台(包含权限认证功能),经过对比最终选择了
BladeX。在使用 BladeX 开发平台的这3年多时间里,也总结下优缺点:
优点:
- 功能强大,提供了整套解决方案,开箱即用
- 有多套版本可控选择,单机版、微服务版
缺点:
- 完全掌握的难度比较大,这估计是大部分使用者的一致感受
- 虽然是基于Spring等开源框架封装,但是活跃度不高
- 文档稍微欠缺,查找问题比较困难
最近考虑自己开发一套管理系统,但是想想10年前自研的艰苦历程,加上也没有经费再搞一次这么大规模的研发项目,就在网上找开源框架来实现。
网上很多博主都在推荐 Sa-Token ,正好有时间研究代码和文档,也试着自己测试相关的功能,目前只跑了注解实现鉴权的功能,发现真的是一个小而美的开源框架,为作者点赞、打赏。
官网也挂了好几个视频,结束框架的使用,我基本都看了,发现讲解都比较粗糙(图灵Fox、架构驿站等),都只是根据官方文档跑了一遍Demo,完全没有详细讲解实现原理和执行过程。
作为程序员老炮,不弄懂原理,以后用起来总是不放心,所以根据源代码的执行过程,决定写几篇文章,跟大家分享下 Sa-Token 的使用心得。
使用 Sa-Token
首先说明一点,我本次分享的经验,是单机版项目,基于 Spring Boot 3.4.5 搭建
-
引入依赖,增加如下依赖:
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot3-starter</artifactId> <version>1.44.0</version> </dependency> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-jwt</artifactId> <version>1.44.0</version> <exclusions> <exclusion> <groupId>cn.hutool</groupId> <artifactId>hutool-jwt</artifactId> </exclusion> </exclusions> </dependency> <!-- Sa-Token 整合 RedisTemplate --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-redis-template</artifactId> <version>1.44.0</version> </dependency> <!-- 提供 Redis 连接池 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <!-- Sa-Token 整合 Fastjson --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-fastjson2</artifactId> <version>1.44.0</version> </dependency> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-alone-redis</artifactId> <version>1.44.0</version> </dependency> -
实现权限数据源加载接口
import cn.dev33.satoken.stp.StpInterface; import jakarta.annotation.Resource; import org.apache.commons.lang3.StringUtils; import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; import java.util.List; /** * 用户权限接口实现类 * * @author : xulong * @version 1.0 * @date : 2025/6/14 23:46 */ @Service public class UserPermissionImpl implements StpInterface { @Resource private Environment env; @Override public List<String> getPermissionList(Object loginId, String loginType) { String roleListStr = env.getProperty("auth.user." + loginId + ".prms.list"); if (StringUtils.isNotBlank(roleListStr)) { return List.of(roleListStr.split(",")); } return List.of(); } @Override public List<String> getRoleList(Object loginId, String loginType) { // auth.user.1002.prms.list // auth.user.1002.role.list String roleListStr = env.getProperty("auth.user." + loginId + ".role.list"); if (StringUtils.isNotBlank(roleListStr)) { return List.of(roleListStr.split(",")); } return List.of(); } } -
数据加载没有使用数据库,而是在配置文件中内置了2个角色和权限集合
auth.user.1001.role.list=admin,user auth.user.1001.prms.list=user-list,user-crdt,user-edit,user-rmvi auth.user.1002.role.list=user auth.user.1002.prms.list=user-list -
登录认证与权限注解:
UserControllerimport cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.stp.StpUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * 用户相关服务接口 * * @author : xulong * @version 1.0 * @date : 2025/6/14 23:06 */ @RestController @RequestMapping("/user") @Tag(name = "用户对外服务接口") public class UserController { @Resource private UserService userService; @SaIgnore // 登录接口不做登录和权限校验 @PostMapping("/login") @Operation(summary = "用户登录", description = "用户登录") public R<?> login(@RequestBody UserDto userDto) { String acctNo = userDto.getAcctNo(); User user = userService.getUserByAcctNo(acctNo); if (user != null) { Long id = user.getId(); String password = user.getPassword(); if (password.equals(userDto.getPassword())) { StpUtil.login(id); return R.success("登录成功"); } } return R.success("登录失败"); } @PostMapping("/list") @SaCheckPermission("user-list") @Operation(summary = "查询所有用户", description = "查询所有用户") public R<?> list() { return R.success("查询所有用户信息"); } @PostMapping("/crdt") @SaCheckPermission("user-crdt") @Operation(summary = "增加用户", description = "增加用户") public R<?> crdt(@RequestBody UserDto userDto) { return R.success("查询所有用户信息"); } @PostMapping("/edit") @SaCheckPermission("user-edit") @Operation(summary = "修饰用户", description = "修改用户") public R<?> edit(@RequestBody UserDto userDto) { return R.success("查询所有用户信息"); } @PostMapping("/rmvi") @SaCheckPermission("user-rmvi") @Operation(summary = "删除用户", description = "删除用户") public R<?> rmvi(@RequestBody UserDto userDto) { return R.success("查询所有用户信息"); } } -
权限校验:初始化拦截器
/** * @author xulong */ @Configuration public class SaTokenConfigure implements WebMvcConfigurer { // 注册拦截器 @Override public void addInterceptors(InterceptorRegistry registry) { // 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。 registry.addInterceptor( new SaInterceptor( handle -> StpUtil.checkLogin() )).addPathPatterns("/**"); } @Bean public StpLogic stpLogic() { return new StpLogicJwtForSimple(); } } -
Sa-Token 全局配置
sa-token: # token 名称(同时也是 cookie 名称) token-name: stk # token 有效期(单位:秒) 默认30天,-1 代表永久有效 timeout: 2592000 # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结 active-timeout: -1 # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录) is-concurrent: true # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token) is-share: false # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik) token-style: simple-uuid # 是否输出操作日志 is-log: true # 指定 token 提交时的前缀 token-prefix: Bearer # jwt秘钥 jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjk # redis 配置 alone-redis: database: 0 host: localhost port: 6379 password: timeout: 10s
认证鉴权原理及流程解析
认证授权流程如下:

-
初始化拦截器代码
/** * @author xulong */ @Configuration public class SaTokenConfigure implements WebMvcConfigurer { // 注册拦截器 @Override public void addInterceptors(InterceptorRegistry registry) { // 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。 registry.addInterceptor( new SaInterceptor( // SaInterceptor 的 preHandle 方法会回调该 lambda 表达式,鉴权工作优先于该 lambda 表达式 handle -> StpUtil.checkLogin() )).addPathPatterns("/**"); } } -
拦截器代码
/** * 每次请求之前触发的方法 */ @Override @SuppressWarnings("all") public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { try { // 前置函数:在注解鉴权之前执行 beforeAuth.run(handler); // 这里必须确保 handler 是 HandlerMethod 类型时,才能进行注解鉴权 if(isAnnotation && handler instanceof HandlerMethod) { Method method = ((HandlerMethod) handler).getMethod(); SaAnnotationStrategy.instance.checkMethodAnnotation.accept(method); } // Auth 路由拦截鉴权校验,回调 SaParamFunction<Object> auth 函数,即 handle -> StpUtil.checkLogin() auth.run(handler); } catch (StopMatchException e) { // StopMatchException 异常代表:停止匹配,进入Controller } catch (BackResultException e) { // BackResultException 异常代表:停止匹配,向前端输出结果 // 请注意此处默认 Content-Type 为 text/plain,如果需要返回 JSON 信息,需要在 back 前自行设置 Content-Type 为 application/json // 例如:SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8"); if(response.getContentType() == null) { response.setContentType("text/plain; charset=utf-8"); } response.getWriter().print(e.getMessage()); return false; } // 通过验证 return true; } -
注解策略器鉴权逻辑:
SaAnnotationStrategy.instance.checkMethodAnnotation.accept(method);/** * 对一个 [Method] 对象进行注解校验 (注解鉴权内部实现) */ public SaCheckMethodAnnotationFunction checkMethodAnnotation = (method) -> { // 如果 Method 或其所属 Class 上有 @SaIgnore 注解,则直接跳过整个校验过程 if(instance.isAnnotationPresent.apply(method, SaIgnore.class)) { SaRouter.stop(); } // 先校验 Method 所属 Class 上的注解 instance.checkElementAnnotation.accept(method.getDeclaringClass()); // 再校验 Method 上的注解 instance.checkElementAnnotation.accept(method); }; Method 或其所属 Class 上有 @SaIgnore 注解,则直接跳过整个校验过程-
检查Controller及方法是否包含
SaIgnore注解:instance.isAnnotationPresent.apply/** * 判断一个 Method 或其所属 Class 是否包含指定注解 */ public SaIsAnnotationPresentFunction isAnnotationPresent = (method, annotationClass) -> { return instance.getAnnotation.apply(method, annotationClass) != null || instance.getAnnotation.apply(method.getDeclaringClass(), annotationClass) != null; }; -
校验 Controller 上注解
/** * 对一个 [Method] 对象进行注解校验 (注解鉴权内部实现) */ public SaCheckMethodAnnotationFunction checkMethodAnnotation = (method) -> { // 如果 Method 或其所属 Class 上有 @SaIgnore 注解,则直接跳过整个校验过程 if(instance.isAnnotationPresent.apply(method, SaIgnore.class)) { SaRouter.stop(); } // 先校验 Method 所属 Class 上的注解 instance.checkElementAnnotation.accept(method.getDeclaringClass()); // 再校验 Method 上的注解 instance.checkElementAnnotation.accept(method); };- annotationHandlerMap 策略器启动的时候,预置的注解以及处理类的映射关系
SaAnnotationHandlerInterface - 获取到权限注解,从
annotationHandlerMap找到对应的处理类,执行校验(check)逻辑 - 在
SaAnnotationHandlerInterface实现类的checkMethod方法中,可能会调用StpInterface#getPermissionList和StpInterface#getRoleList获取角色和权限信息,用于跟当前注解的比对
- annotationHandlerMap 策略器启动的时候,预置的注解以及处理类的映射关系
-
校验 Method 上注解,同Controller校验逻辑相同
-
560

被折叠的 条评论
为什么被折叠?



