01 Sa-Token 认证授权-注解实现鉴权原理及流程

Sa-Token 注解认证授权流程


前言
  • 介绍下使用背景

    • 本人近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年多时间里,也总结下优缺点:

    优点

    1. 功能强大,提供了整套解决方案,开箱即用
    2. 有多套版本可控选择,单机版、微服务版

    缺点

    1. 完全掌握的难度比较大,这估计是大部分使用者的一致感受
    2. 虽然是基于Spring等开源框架封装,但是活跃度不高
    3. 文档稍微欠缺,查找问题比较困难

    最近考虑自己开发一套管理系统,但是想想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
    
  • 登录认证与权限注解:UserController

    import 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
    
认证鉴权原理及流程解析

认证授权流程如下:

注解认证授权流程

  1. 初始化拦截器代码

        /**
         * @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("/**");
            }
        }
    
  2. 拦截器代码

        /**
         * 每次请求之前触发的方法 
         */
        @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;
        }
    
  3. 注解策略器鉴权逻辑: 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#getPermissionListStpInterface#getRoleList 获取角色和权限信息,用于跟当前注解的比对
    • 校验 Method 上注解,同Controller校验逻辑相同

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值