likes
comments
collection
share

开发札记:基于Sa-Token构建权限系统实战

作者站长头像
站长
· 阅读数 4

Sa-Token是一个Java权限认证框架,配置很简洁,使用方便。本文主要分享如何使用Sa-Token整合JWT实现登录鉴权和权限授权,数据持久层采用的是Redis缓存,同时本文会分析Sa-Token的相关源码。

Maven依赖和yml配置

首先引入Sa-Token的两个依赖。

<!-- Sa-Token 权限认证, 在线文档:http://sa-token.dev33.cn/ -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
</dependency>
<!-- Sa-Token 整合 jwt -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-jwt</artifactId>
</dependency>

在application.yml可以进行配置,常见配置如token名字、token有效期、是否允许并发登录、token前缀、jwt密钥等。

# Sa-Token配置
sa-token:
  # token名称 (同时也是cookie名称)
  token-name: Authorization
  # token有效期 设为一天 (必定过期) 单位: 秒
  timeout: 86400
  # token最低活跃时间 (指定时间无操作就过期) 单位: 秒
  active-timeout: 1800
  # 允许动态设置 token 有效期
  dynamic-active-timeout: true
  # 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
  is-concurrent: true
  # 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
  is-share: false
  # 是否尝试从header里读取token
  is-read-header: true
  # 是否尝试从cookie里读取token
  is-read-cookie: false
  # token前缀
  token-prefix: "Bearer"
  # jwt秘钥
  jwt-secret-key: abcdefghijklmnopqrstuvwxyz

自定义配置类:SaTokenConfig

作为一个权限认证框架,肯定是要实现拦截器的功能的。因此我们的配置类需要实现WebMvcConfigurer,用于添加拦截器。

在配置类中,我们做四件事情,分别是:添加拦截器SaInterceptor、注入StpLogicJwtForSimple实现JWT模式、注入权限接口实现SaPermissionImpl,注入使用Redis实现的自定义DAO层。

添加拦截器

重写void addInterceptors(InterceptorRegistry registry),添加拦截器SaInterceptor

逻辑大致如下:

  1. 通过AllUrlHandler,可以拿到所有url路径。
  2. 使用SaRouter路由匹配操作工具类,调用match传入拦截的URL列表。
  3. 链式调用check,使用Sa-Token自带的权限认证工具类StpUtil进行校验登录。
  4. 拦截器调用excludePathPatterns放行一些静态资源等排除路径。

PS:我们自定义一个SecurityProperties,内部包含一个字符串数组,用于配置排除路径。

小插一嘴:这个AllUrlHandler参考自开源项目RuoYi-Vue-Plus,它的大致原理是通过实现InitializingBean接口,重写afterPropertiesSet方法,这个是Spring提供的扩展点,在Bean属性设置后执行,Spring MVC中的RequestMappingHandlerMapping就实现了InitializingBean接口,在afterPropertiesSet中完成了一些初始化工作,比如url和controller方法的映射。

这里AllUrlHandler就是从容器拿到RequestMappingHandlerMapping,遍历内部的RequestMappingInfo,拿到pattern并添加到集合中返回。需要稍微了解Spring Bean的生命周期,还是挺有意思的。

    /**
     * 注册sa-token的拦截器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册路由拦截器,自定义验证规则
        registry.addInterceptor(new SaInterceptor(handler -> {
            AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
            // 登录验证 -- 排除多个路径
            SaRouter
                // 获取所有的
                .match(allUrlHandler.getUrls())
                // 对未排除的路径进行检查
                .check(() -> {
                    // 检查是否登录 是否有token
                    StpUtil.checkLogin();
                });
        })).addPathPatterns("/**")
            // 排除不需要拦截的路径
            .excludePathPatterns(securityProperties.getExcludes());
    }

注入几个Bean实现DIY

  • 注入:StpLogicJwtForSimple,整合JWT。
  • 注入:SaPermissionImpl,实现权限管理。
  • 注入:RedisSaTokenDao,自定义DAO层存储,整合Redis。

自定义DAO层:基于Redis

SaTokenDao是Sa-Token 持久层接口,sa-token本身封装了基于内存的默认实现,因为不满足持久化的需求所以不适用。

因此,这里采用自定义持久层的方式来实现,具体实现的话,只需要实现SaTokenDao接口,重写一系列set和get方法即可。这里我选择的实现方式为基于Redission客户端封装的RedisUtils,RedisUtils是RuoYi-Vue-Plus封装的工具类,基于jackson实现序列化,覆盖了大部分Redis的使用场景。

Sa-Token如何存储token值?

这里就要介绍Sa-Token内部的几个类:

  • SaHolder:上下文持有类,用于快速获取SaRequest、SaResponse、SaStorage。
  • SaTokenContext:上下文。可以共享部分数据。
  • SaStorage:在一次请求的作用域内读写值,可以在不同方法间隐式传参

Sa-Token在登陆后将token存储在Storage和Dao层,采用的存储策略是多级缓存。

功能实现:登录验证

登录方法

SaLoginModel类:SaToken的登陆模型,决定登录的一些细节行为,包含设备信息、usedId等。

现在,我们从Controller层开始,解析login方法的执行流程。

Controller层:拿到username和password,调用service层的login,将token包装返回。

Service层

  1. 根据用户名,查数据库拿到SysUser的对象。
  2. 调用checkLogin检查密码合法性。
  3. 如果没有抛出异常,构建LoginUser登录对象。
  4. 将用户信息存储入Redis和上下文,执行StpUtils.login方法。
  5. 采用异步+线程池方式记录日志。

这里我们注重关注第二步和第四步。

checkLogin(LoginType loginType, String username, Supplier supplier)

此方法记录用户失败重试次数,调用传入的supplier进行密码校验,一般来讲supplier传入BCrypt.checkpw(password, user.getPassword())比对密码和数据库内密码。每次错误都会将错误次数存入Redis,达到指定次数会直接抛出异常。

LoginUser是登录用户,内含用户基本信息、权限信息、菜单信息等。

如果执行到构建LoginUser,已经验证成功了,下一步就是如何生成token并将用户信息存储到Redis。

  1. 存储loginUser、userId到SaStorage。
  2. 构建SaLoginModel,将userId存到SaLoginModel。
  3. 调用StpUtils.login(Object id, SaLoginModel loginModel)
    1. 创建登录会话,使用StpLogicJwtForSimple分配token。
    2. 续期会话,添加token签名并设置到Redis
    3. 在Redis内写入token到loginId的映射关系,方便check的时候查找token合法性。
    4. 发布事件:登陆成功。用于监听后记录日志、实现在线用户功能等,实现切面操作。
    5. 存储:将TokenValue写入到Storage、Header。
  4. 调用StpUtil.getTokenSession().set(LOGIN_USER_KEY, loginUser);,将LoginUser写入SaSession缓存。

至此,登录方法分析完毕,更加深入的内容读者可以自行阅读Sa-Token源码。

SaInterceptor拦截器

checkLogin()方法

我们前面已经在SaTokenConfig配置了请求拦截器,获取所有URL并且调用checkLogin方法,现在我们深入CheckLogin方法的内部。

checkLogin方法底层调用了getLoginId获取登录会话ID,如果找不到就抛出异常。而在这个方法内部调用了getTokenValue方法,底层采用多级缓存的获取方法,先从Storage获取、然后依次从Request、Header获取。如果都获取不到,token就是null,自然无法登录。

值得注意的是,获取Token的方法并没有从DAO层获取,而是从缓存中获取。当从缓冲中得到token后,会调用getLoginIdNotHandle查找此Token对应的loginId,此时调用的是DAO层从Redis中读取,如果获取不到就证明token无效,抛出异常。

总结:调用getTokenValue从缓存拿token,调用getLoginIdNotHandle从Redis查询loginId,检查token是否有效。

SaInterceptor的preHandle方法

使用SaStrategy.instance.isAnnotationPresent.apply(method, SaIgnore.class)判断是否加了SaIgnore注解,如果加了直接返回,否则执行注解鉴权,最后调用我们传入的Lambda表达式进行鉴权拦截。

功能实现:权限功能

权限功能底层都是调用StpInterface的相关API进行获取权限,在这里我们写一个实现类重写所有方法。

主要方法包括:

  • 获取权限列表:直接从LoginUser里面拿,我们在login的时候已经将权限信息设置进入。
  • 获取角色列表

如何获取LoginUser

前面我们已经将LoginUser存入多个缓存,因此可以采用多级缓存的方式进行获取。

我们首先从Storage中拿到loginUser,然后从SaSession获取,底层还是先从多级缓存获取,然后最后从Dao层即Redis获取。

具体鉴权方式1:方法

// 获取:当前账号所拥有的权限集合
StpUtil.getPermissionList();
// 判断:当前账号是否含有指定权限, 返回 true 或 false
StpUtil.hasPermission("user.add");		
// 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException 
StpUtil.checkPermission("user.add");		
// 校验:当前账号是否含有指定权限 [指定多个,必须全部验证通过]
StpUtil.checkPermissionAnd("user.add", "user.delete", "user.get");		
// 校验:当前账号是否含有指定权限 [指定多个,只要其一验证通过即可]
StpUtil.checkPermissionOr("user.add", "user.delete", "user.get");	
// 获取:当前账号所拥有的角色集合
StpUtil.getRoleList();
// 判断:当前账号是否拥有指定角色, 返回 true 或 false
StpUtil.hasRole("super-admin");		
// 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
StpUtil.checkRole("super-admin");		
// 校验:当前账号是否含有指定角色标识 [指定多个,必须全部验证通过]
StpUtil.checkRoleAnd("super-admin", "shop-admin");		
// 校验:当前账号是否含有指定角色标识 [指定多个,只要其一验证通过即可] 
StpUtil.checkRoleOr("super-admin", "shop-admin");		

鉴权方式2:注解(常用),以下列出了常用的注解

  • @SaIgnore:不验证
  • @SaCheckPermission("monitor:logininfor:remove"):验证权限
  • @SaCheckRole("super-admin")
  • @SaCheckDisable("comment")

参考文档

  1. Sa-Token官方文档
  2. RuoYi-Vue-Plus