开发札记:基于Sa-Token构建权限系统实战
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
。
逻辑大致如下:
- 通过AllUrlHandler,可以拿到所有url路径。
- 使用SaRouter路由匹配操作工具类,调用match传入拦截的URL列表。
- 链式调用check,使用Sa-Token自带的权限认证工具类StpUtil进行校验登录。
- 拦截器调用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层:
- 根据用户名,查数据库拿到SysUser的对象。
- 调用checkLogin检查密码合法性。
- 如果没有抛出异常,构建LoginUser登录对象。
- 将用户信息存储入Redis和上下文,执行StpUtils.login方法。
- 采用异步+线程池方式记录日志。
这里我们注重关注第二步和第四步。
checkLogin(LoginType loginType, String username, Supplier
supplier)
此方法记录用户失败重试次数,调用传入的supplier进行密码校验,一般来讲supplier传入 BCrypt.checkpw(password, user.getPassword())
比对密码和数据库内密码。每次错误都会将错误次数存入Redis,达到指定次数会直接抛出异常。
LoginUser是登录用户,内含用户基本信息、权限信息、菜单信息等。
如果执行到构建LoginUser,已经验证成功了,下一步就是如何生成token并将用户信息存储到Redis。
- 存储loginUser、userId到SaStorage。
- 构建SaLoginModel,将userId存到SaLoginModel。
- 调用
StpUtils.login(Object id, SaLoginModel loginModel)
- 创建登录会话,使用StpLogicJwtForSimple分配token。
- 续期会话,添加token签名并设置到Redis
- 在Redis内写入token到loginId的映射关系,方便check的时候查找token合法性。
- 发布事件:登陆成功。用于监听后记录日志、实现在线用户功能等,实现切面操作。
- 存储:将TokenValue写入到Storage、Header。
- 调用
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")
参考文档
- 感谢你赐予我前进的力量