集成sa-token实现登录和RBAC权限控制


1.sa-token是什么?

1.1简介

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证权限认证单点登录OAuth2.0分布式Session会话微服务网关鉴权 等一系列权限相关问题。


1.2官网

https://sa-token.cc/v/v1.36.0/doc.html#/


1.3 Sa-Token 功能一览

Sa-Token 目前主要五大功能模块:登录认证、权限认证、单点登录、OAuth2.0、微服务鉴权。

  • 登录认证 —— 单端登录、多端登录、同端互斥登录、七天内免登录
  • 权限认证 —— 权限认证、角色认证、会话二级认证
  • Session会话 —— 全端共享Session、单端独享Session、自定义Session
  • 踢人下线 —— 根据账号id踢人下线、根据Token值踢人下线
  • 账号封禁 —— 登录封禁、按照业务分类封禁、按照处罚阶梯封禁
  • 持久层扩展 —— 可集成Redis、Memcached等专业缓存中间件,重启数据不丢失
  • 分布式会话 —— 提供jwt集成、共享数据中心两种分布式会话方案
  • 微服务网关鉴权 —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证
  • 单点登录 —— 内置三种单点登录模式:无论是否跨域、是否共享Redis,都可以搞定
  • OAuth2.0认证 —— 轻松搭建 OAuth2.0 服务,支持openid模式
  • 二级认证 —— 在已登录的基础上再次认证,保证安全性
  • Basic认证 —— 一行代码接入 Http Basic 认证
  • 独立Redis —— 将权限缓存与业务缓存分离
  • 临时Token认证 —— 解决短时间的Token授权问题
  • 模拟他人账号 —— 实时操作任意用户状态数据
  • 临时身份切换 —— 将会话身份临时切换为其它账号
  • 前后端分离 —— APP、小程序等不支持Cookie的终端
  • 同端互斥登录 —— 像QQ一样手机电脑同时在线,但是两个手机上互斥登录
  • 多账号认证体系 —— 比如一个商城项目的user表和admin表分开鉴权
  • Token风格定制 —— 内置六种Token风格,还可:自定义Token生成策略、自定义Token前缀
  • 注解式鉴权 —— 优雅的将鉴权与业务代码分离
  • 路由拦截式鉴权 —— 根据路由拦截鉴权,可适配restful模式
  • 自动续签 —— 提供两种Token过期策略,灵活搭配使用,还可自动续签
  • 会话治理 —— 提供方便灵活的会话查询接口
  • 记住我模式 —— 适配[记住我]模式,重启浏览器免验证
  • 密码加密 —— 提供密码加密模块,可快速MD5、SHA1、SHA256、AES、RSA加密
  • 全局侦听器 —— 在用户登陆、注销、被踢下线等关键性操作时进行一些AOP操作
  • 开箱即用 —— 提供SpringMVC、WebFlux等常见web框架starter集成包,真正的开箱即用


1.4 功能结构图


2.集成sa-token及配置

2.1 pom依赖

sa-token的依赖组件也很多,根据自己的需求去官方网站参考引入即可:
       <dependency>            <groupId>cn.dev33groupId>            <artifactId>sa-token-spring-boot-starterartifactId>            <version>1.37.0version>        dependency>        <dependency>            <groupId>cn.dev33groupId>            <artifactId>sa-token-jwtartifactId>            <version>1.37.0version>        dependency>        <dependency>            <groupId>cn.dev33groupId>            <artifactId>sa-token-redis-jacksonartifactId>            <version>1.37.0version>        dependency>        <dependency>            <groupId>cn.dev33groupId>            <artifactId>sa-token-spring-aopartifactId>            <version>1.37.0version>        dependency>

2.2 yaml配置

sa-token:  # token 名称(同时也是 cookie 名称)  token-name: satoken  token-prefix: Bearer  # token 有效期(单位:秒) 默认30天,-1 代表永久有效 这里设置为1天  timeout: 86400  # 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: uuid  # 是否输出操作日志  is-log: true  # jwt秘钥  jwt-secret-key: adfdfdsdasdasifdfdffhueuiwyudfdfddfdfsfsdfrfewbfjsdafjk
解决反向代理 uri 丢失的问题
https://sa-token.cc/v/v1.36.0/doc.html#/fun/curr-domain


2.3 代码配置

SaTokenConfigure配置jwt简单模式、全局过滤器SaServletFilter,RestResponse该类是自定义响应前端的类,可以自己去定义写,下面的代码只是一个大概的雏形,项目使用前后端分离的方式所以需要使用SaServletFilter的方式配置全局过滤器(所以不使用拦截器的方式配置),下面的配置决绝了跨越问题,配合上面引入的sa-token-spring-aop注解权限校验,在任意地方可以使用sa-token的注解鉴权了(@SaIgnore:不拦截,直接放行;@SaCheckPermission("xxx.xxxx"):有xxx.xxxx权限才可以访问,官方还支持很多注解权限校验注解的),注意:sa-token-spring-aop + 全局过滤器SaServletFilter这种配合使用是没有啥问题的,使用拦截器的方式就不用引入sa-token-spring-aop了,拦截器默认只是控制到controller层,而sa-token-spring-aop + 全局过滤器SaServletFilter的方式是可以在任意位置都可以使用注解权限校验。
https://sa-token.cc/v/v1.36.0/doc.html#/plugin/aop-at
https://sa-token.cc/v/v1.36.0/doc.html#/use/at-check
#使用 Sa-Token 的全局过滤器解决跨域问题(三种方式全版)
https://juejin.cn/post/7247376558367981627

SaTokenConfigure配置

package xxxxx.config;
import cn.dev33.satoken.SaManager;import cn.dev33.satoken.context.SaHolder;import cn.dev33.satoken.exception.SaTokenException;import cn.dev33.satoken.filter.SaServletFilter;import cn.dev33.satoken.jwt.StpLogicJwtForSimple;import cn.dev33.satoken.router.SaHttpMethod;import cn.dev33.satoken.router.SaRouter;import cn.dev33.satoken.stp.StpLogic;import cn.dev33.satoken.stp.StpUtil;import xxxxx.RestResponse;import lombok.extern.slf4j.Slf4j;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Slf4j@Configurationpublic class SaTokenConfigure implements WebMvcConfigurer {
// Sa-Token 整合 jwt (Simple 简单模式) @Bean public StpLogic getStpLogicJwt() { return new StpLogicJwtForSimple(); }
/** * 注册 [Sa-Token 全局过滤器] */ @Bean public SaServletFilter getSaServletFilter() { return new SaServletFilter() // 指定 [拦截路由] 与 [放行路由] .addInclude("/**") // 登录认证 -- 拦截所有路由,并排除/user/login 用于开放登录 .addExclude("/user/**") .addExclude("/favicon.ico") .addExclude("*.js") .addExclude("*.css") // 认证函数: 每次请求执行 .setAuth(obj -> { SaManager.getLog().debug("----- 请求path={} 提交token={}", SaHolder.getRequest().getRequestPath(), StpUtil.getTokenValue()); // ... SaRouter.match("/**") // 拦截的 path 列表,可以写多个 .check(r -> StpUtil.checkLogin());// 要执行的校验动作,可以写完整的 lambda 表达式
// 根据路由划分模块,不同模块不同鉴权 SaRouter.match("/xxx/xxxx/**", r -> StpUtil.checkPermission("xxxx.xxx")); ,,,,,,,,,,,,,,,,,,,,, ,,,,,,,,,,,,,,,,,,,,, ,,,,,,,,,,,,,,,,,,,,, // 更多拦截处理方式,请参考“路由拦截式鉴权”章节 */ })
// 异常处理函数:每次认证函数发生异常时执行此函数 .setError(e1 -> { log.error("sa-token异常:{}", e1.getMessage()); // 设置响应头 SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8"); /** * sa-token登录相关异常处理 * https://sa-token.cc/v/v1.36.0/doc.html#/fun/exception-code */ if (e1 instanceof SaTokenException) { SaTokenException e = (SaTokenException) e1; // 根据不同异常细分状态码返回不同的提示 if (e.getCode() == 11001) { return RestResponse.fail("未能读取到有效Token"); } if (e.getCode() == 11002) { return RestResponse.fail("登录时的账号为空"); } if (e.getCode() == 11011) { return RestResponse.fail("未能读取到有效Token"); } if (e.getCode() == 11012) { return RestResponse.fail("Token无效"); } if (e.getCode() == 11013) { return RestResponse.fail("Token已过期"); } if (e.getCode() == 11014) { return RestResponse.fail("Token已被顶下线"); } if (e.getCode() == 11015) { return RestResponse.fail("Token已被踢下线"); } if (e.getCode() == 11016) { return RestResponse.fail("Token已被冻结"); } if (e.getCode() == 11017) { return RestResponse.fail("未按照指定前缀提交token"); } if (e.getCode() == 11041) { return RestResponse.fail("缺少指定的角色"); } if (e.getCode() == 11051) { return RestResponse.fail("缺少指定的权限"); } if (e.getCode() == 11061) { return RestResponse.fail("当前账号未通过服务封禁校验"); } if (e.getCode() == 11062) { return RestResponse.fail("提供要解禁的账号无效"); } if (e.getCode() == 12001) { return RestResponse.fail("请求中缺少指定的参数"); } if (e.getCode() == 12111) { return RestResponse.fail("密码md5加密异常"); } if (e.getCode() == 30201) { return RestResponse.fail("对jwt字符串解析失败"); } if (e.getCode() == 30202) { return RestResponse.fail("此jwt的签名无效"); } if (e.getCode() == 30203) { return RestResponse.fail("此jwt的loginType字段不符合预期"); } if (e.getCode() == 30204) { return RestResponse.fail("此jwt已超时"); } if (e.getCode() == 30205) { return RestResponse.fail("没有配置jwt秘钥"); } if (e.getCode() == 30206) { return RestResponse.fail("登录时提供的账号为空"); } // 更多 code 码判断 ... // 默认的提示 return RestResponse.fail("登录异常,请联系管理员处理..."); } return RestResponse.fail(e1.getMessage()); })
// 前置函数:在每次认证函数之前执行 .setBeforeAuth(obj -> { // ---------- 设置一些安全响应头 ---------- SaHolder.getResponse() // 服务器名称 //.setServer("sa-server") // 是否可以在iframe显示视图:DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以 .setHeader("X-Frame-Options", "SAMEORIGIN") // 是否启用浏览器默认XSS防护:0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面 .setHeader("X-XSS-Protection", "1; mode=block") // 禁用浏览器内容嗅探 .setHeader("X-Content-Type-Options", "nosniff") // ---------- 设置跨域响应头 ---------- // 允许指定域访问跨域资源 .setHeader("Access-Control-Allow-Origin", "*") // 允许所有请求方式 .setHeader("Access-Control-Allow-Methods", "*") // 允许的header参数 .setHeader("Access-Control-Allow-Headers", "*") // 有效时间 .setHeader("Access-Control-Max-Age", "3600");
// 如果是预检请求,则立即返回到前端 SaRouter.match(SaHttpMethod.OPTIONS) .free(r -> log.info("--------OPTIONS预检请求,不做处理")) .back(); }); }
}

自定义权限加载接口实现类:

package xxxxx.config;
import cn.dev33.satoken.stp.StpInterface;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;
import java.io.Serializable;import java.util.ArrayList;import java.util.List;import java.util.Objects;
/** * 自定义权限加载接口实现类 * 保证此类被 SpringBoot 扫描,完 * 成 Sa-Token 的自定义权限验证扩展 */@Componentpublic class StpInterfaceImpl implements StpInterface {
/** * 返回一个账号所拥有的权限码集合 */ @Override public List<String> getPermissionList(Object loginId, String loginType) { List<String> permissionList = new ArrayList<>(); //TODO 根据登录的loginId(登录用户id)去查权限,可以存缓存中,从缓存中取,权限有变动更新缓存 return permissionList; }
/** * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验) */ @Override public List<String> getRoleList(Object loginId, String loginType) { List<String> roleList = new ArrayList<>(); //TODO 根据登录的loginId(登录用户id)去查角色,可以存缓存,从缓存中取,角色变动更新缓存 return roleList; }
}

4.RBAC权限控制表设计

RBAC:基于角色的访问控制(需要实现对用户、角色、资源的管理)
用户-角色-资源之间的对应关系是多对多的一个关系

角色表-role表:

资源表-resource表:

角色所拥有的资源权限表-role_resource_power表:

角色用户表-role-admin(role-user)表:

资源表-resource表里面有一个父级id,顶级父类的父类id是0或者是null,子资源需要设置所属哪个父资源下,所以就需要设置子资源的父级id,这种关系就形成了一颗菜单权限树。


5.菜单权限树构造实现

5.1菜单权限数据sql查询

with recursive menu_power_tree(id, parent_id, type, name,remarks,source_type,menu_sort,menu_level) AS (  -- 初始查询,选择所有没有父级别的分类(即根分类)  SELECT id, parent_id, type, name,remarks,source_type,menu_sort,menu_level  FROM dyict_resource  WHERE parent_id = 0 and source_type = 1   UNION ALL   -- 递归查询,选择所有子分类  SELECT  c1.id, c1.parent_id, c1.type, c1.name, c1.remarks,c1.source_type,c1.menu_sort,c1.menu_level FROM  (  SELECT id, parent_id, type, name,remarks,source_type,menu_sort,menu_level  FROM dyict_resource c WHERE c.parent_id <> 0  ) c1  INNER JOIN menu_power_tree ct ON ct.id = c1.parent_id )SELECT a.id, a.parent_id, a.type, a.name,a.remarks,a.source_type,a.menu_sort,a.menu_level,(SELECT count(*) FROM dyict_role_resource_power b WHERE a.id = b.resource_id and b.role_id in(1)) as pFROM menu_power_tree aORDER BY parent_id,id;


5.2菜单权限树构建

基础接口RoleResourcePowerMapper:

public interface RoleResourcePowerMapper extends BaseMapper<RoleResourcePower> {

/** * 角色对应的菜单权限用于获取权限和构建权限 * * @param sourceType * @param roleIds * @return */ List menuPowerTree(@Param("sourceType") Integer sourceType, @Param("roleIds") List roleIds);

}

RoleResourcePowerMapper.xml

<select id="menuPowerTree" resultType="xxxx.dto.MenuPowerTreeDto">        with recursive menu_power_tree(id, parent_id, type, name,remarks,source_type,menu_sort,menu_level) AS (        -- 初始查询,选择所有没有父级别的分类(即根分类)        SELECT id, parent_id, type, name,remarks,source_type,menu_sort,menu_level        FROM dyict_resource        WHERE parent_id = 0        <if test="sourceType != null">            and source_type = #{sourceType}        if>        UNION ALL        -- 递归查询,选择所有子分类        SELECT c1.id, c1.parent_id, c1.type, c1.name, c1.remarks,c1.source_type,c1.menu_sort,c1.menu_level FROM        (        SELECT id, parent_id, type, name,remarks,source_type,menu_sort,menu_level        FROM dyict_resource c WHERE c.parent_id  ]]>0        ) c1        INNER JOIN menu_power_tree ct ON ct.id = c1.parent_id        )        SELECT a.id, a.parent_id, a.type, a.name,a.remarks,a.source_type,a.menu_sort,a.menu_level,        (SELECT count(*) FROM dyict_role_resource_power b WHERE a.id = b.resource_id        <if test="roleIds != null and roleIds.size() > 0 ">            and            b.role_id in            <foreach collection="roleIds" item="id" index="index" open="(" close=")" separator=",">                #{id}            foreach>        if>        ) as p        FROM menu_power_tree a        ORDER BY parent_id,id    select>

MenuPowerTreeDto类:

package xxxx.dto;
import lombok.Data;
import java.io.Serializable;
@Datapublic class MenuPowerTreeDto implements Serializable {
private static final long serialVersionUID = -8644290706362470684L;
private Integer id;
private Integer parentId;
private Integer type;
private String name;
private String remarks;
private Integer sourceType;
private Integer menuSort;
private Integer menuLevel;
private Integer p;
}

MenuPowerTreeVo类:

package xxxx.vo;
import lombok.Data;
import java.io.Serializable;import java.util.List;
@Datapublic class MenuPowerTreeVo implements Serializable {
private static final long serialVersionUID = 3214808951975328795L;
private Integer id;
private Integer parentId;
private Integer type;
private String name;
private String remarks;
private Integer sourceType;
private Integer menuSort;
private Integer menuLevel;
private Boolean hasPower;
private List childrenMenuType;
private List childrenButtonType;
}

RoleResourcePowerServiceImpl类:

package xxxxx.service.impl;
import cn.hutool.core.collection.CollectionUtil;import com.alibaba.fastjson.JSON;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.BeanUtils;import org.springframework.stereotype.Service;
import java.util.ArrayList;import java.util.Arrays;import java.util.Collections;import java.util.Comparator;import java.util.List;import java.util.Objects;import java.util.stream.Collectors;
@Slf4j@Servicepublic class RoleResourcePowerServiceImpl extends ServiceImpl<RoleResourcePowerMapper, RoleResourcePower> implements RoleResourcePowerService {
@Override public MenuPowerTreeVo queryMenuPowerTreeVo(Integer sourceType, Integer roleId) { if (Objects.nonNull(sourceType) && Objects.nonNull(roleId)) { List menuPowerTrees = this.baseMapper.menuPowerTree(sourceType, Arrays.asList(roleId)); if (CollectionUtil.isNotEmpty(menuPowerTrees)) { MenuPowerTreeVo menuPowerTreeVo = new MenuPowerTreeVo(); List oneMenuPowerTrees = menuPowerTrees.stream().filter(e -> e.getParentId() == 0).collect(Collectors.toList()); if (CollectionUtil.isNotEmpty(oneMenuPowerTrees)) { this.buildeTree(oneMenuPowerTrees, menuPowerTrees, menuPowerTreeVo); if (oneMenuPowerTrees.size() == 1) { List children = new ArrayList<>(); children.add(menuPowerTreeVo); MenuPowerTreeVo menuPowerTreeVo2 = new MenuPowerTreeVo(); menuPowerTreeVo2.setId(-1); menuPowerTreeVo2.setName("父节点"); menuPowerTreeVo2.setParentId(-1); menuPowerTreeVo2.setMenuSort(0); menuPowerTreeVo2.setChildrenMenuType(children); return menuPowerTreeVo2; } return menuPowerTreeVo; } } } return null; }
private void buildeTree(List oneMenuPowerTrees, List menuPowerTrees, MenuPowerTreeVo menuPowerTreeVo) { if (oneMenuPowerTrees.size() > 1) { menuPowerTreeVo.setId(-1); menuPowerTreeVo.setName("父节点"); menuPowerTreeVo.setParentId(-1); menuPowerTreeVo.setMenuSort(0); List childrens = oneMenuPowerTrees; this.commonBuildTree(menuPowerTrees, menuPowerTreeVo, childrens); } else if (oneMenuPowerTrees.size() == 1) { for (MenuPowerTreeDto mp : oneMenuPowerTrees) { BeanUtils.copyProperties(mp, menuPowerTreeVo); if (Objects.nonNull(mp.getP()) && mp.getP() > 0) { menuPowerTreeVo.setHasPower(true); } Integer id = mp.getId(); List childrens = menuPowerTrees.stream().filter(e -> Objects.nonNull(e.getParentId()) && e.getParentId() == id).collect(Collectors.toList()); this.commonBuildTree(menuPowerTrees, menuPowerTreeVo, childrens); } } }
private void commonBuildTree(List menuPowerTrees, MenuPowerTreeVo menuPowerTreeVo, List childrens) { if (CollectionUtil.isNotEmpty(childrens)) { List childrenButtonTypeDto = childrens.stream().filter(e -> Objects.nonNull(e.getType()) && e.getType() == 2).collect(Collectors.toList()); if (CollectionUtil.isNotEmpty(childrenButtonTypeDto)) { List childrenButtonType = new ArrayList<>(); for (MenuPowerTreeDto mb : childrenButtonTypeDto) { MenuPowerTreeVo menuPowerTreeVo2 = new MenuPowerTreeVo(); BeanUtils.copyProperties(mb, menuPowerTreeVo2); if (Objects.nonNull(mb.getP()) && mb.getP() > 0) { menuPowerTreeVo2.setHasPower(true); } childrenButtonType.add(menuPowerTreeVo2); } menuPowerTreeVo.setChildrenButtonType(childrenButtonType); } List childrenMenuTypeDto = childrens.stream().filter(e -> Objects.nonNull(e.getType()) && e.getType() == 1).collect(Collectors.toList()); if (CollectionUtil.isNotEmpty(childrenMenuTypeDto)) { List childrenMenuType = new ArrayList<>(); for (MenuPowerTreeDto mp2 : childrenMenuTypeDto) { MenuPowerTreeVo menuPowerTreeVo1 = new MenuPowerTreeVo(); List oneMenuType = new ArrayList<>(); oneMenuType.add(mp2); this.buildeTree(oneMenuType, menuPowerTrees, menuPowerTreeVo1); childrenMenuType.add(menuPowerTreeVo1); } Collections.sort(childrenMenuType, Comparator.comparing(MenuPowerTreeVo::getMenuSort)); menuPowerTreeVo.setChildrenMenuType(childrenMenuType); } } }
@Override public List queryMenuPower(Integer sourceType, List roleIds) { List powerList = new ArrayList<>(); if (Objects.nonNull(sourceType) && CollectionUtil.isNotEmpty(roleIds)) { List menuPowerTrees = this.baseMapper.menuPowerTree(sourceType, roleIds); if (CollectionUtil.isNotEmpty(menuPowerTrees)) { List hasPower = menuPowerTrees.stream().filter(e -> Objects.nonNull(e.getP()) && e.getP() > 0).collect(Collectors.toList()); if (CollectionUtil.isNotEmpty(hasPower)) { for (MenuPowerTreeDto p : hasPower) { powerList.add(p.getName()); } } } } log.info("queryMenuPower.sourceType:{}.roleIds:{}.powerList:{}", sourceType, JSON.toJSONString(roleIds), JSON.toJSONString(powerList)); return powerList; }
}

实现效果:

{    "code": "000000",    "msg": "success",    "timestamp": "2024-04-07 17:38:35",    "data": {        "id": -1,        "parentId": -1,        "type": null,        "name": "父节点",        "remarks": null,        "sourceType": null,        "menuSort": 0,        "menuLevel": null,        "hasPower": null,        "childrenMenuType": [            {                "id": 3,                "parentId": 0,                "type": 1,                "name": "xxx.xxx",                "remarks": "xxx.xxx",                "sourceType": 1,                "menuSort": 1,                "menuLevel": 1,                "hasPower": true,                "childrenMenuType": [                    {                        "id": 4,                        "parentId": 3,                        "type": 1,                        "name": "xxx.xxx",                        "remarks": "xxx.xxx",                        "sourceType": 1,                        "menuSort": 1,                        "menuLevel": 2,                        "hasPower": true,                        "childrenMenuType": null,                        "childrenButtonType": Array[2]                    },                    {                        "id": 5,                        "parentId": 3,                        "type": 1,                        "name": "xxx.xxx",                        "remarks": "xxx.xxx",                        "sourceType": 1,                        "menuSort": 2,                        "menuLevel": 2,                        "hasPower": true,                        "childrenMenuType": null,                        "childrenButtonType": Array[1]                    },                    {                        "id": 6,                        "parentId": 3,                        "type": 1,                        "name": "xxx.xxx",                        "remarks": "xxx.xxx",                        "sourceType": 1,                        "menuSort": 3,                        "menuLevel": 2,                        "hasPower": null,                        "childrenMenuType": null,                        "childrenButtonType": Array[9]                    },                    {                        "id": 7,                        "parentId": 3,                        "type": 1,                        "name": "xxx.xxx",                        "remarks": "xxx.xxx",                        "sourceType": 1,                        "menuSort": 4,                        "menuLevel": 2,                        "hasPower": null,                        "childrenMenuType": null,                        "childrenButtonType": null                    },                    {                        "id": 22,                        "parentId": 3,                        "type": 1,                        "name": "xxx.xxx",                        "remarks": "xxx.xxx",                        "sourceType": 1,                        "menuSort": 5,                        "menuLevel": 2,                        "hasPower": null,                        "childrenMenuType": null,                        "childrenButtonType": Array[3]                    },                    {                        "id": 23,                        "parentId": 3,                        "type": 1,                        "name": "xxx.xxx",                        "remarks": "xxx.xxx",                        "sourceType": 1,                        "menuSort": 6,                        "menuLevel": 2,                        "hasPower": null,                        "childrenMenuType": null,                        "childrenButtonType": Array[1]                    }                ],                "childrenButtonType": Array[2]            }        ],        "childrenButtonType": null    }}
==========================================
{ "code": "000000", "msg": "success", "timestamp": "2024-04-07 17:39:19", "data": { "id": -1, "parentId": -1, "type": null, "name": "父节点", "remarks": null, "sourceType": null, "menuSort": 0, "menuLevel": null, "hasPower": null, "childrenMenuType": [ { "id": 28, "parentId": 0, "type": 1, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 1, "menuLevel": null, "hasPower": true, "childrenMenuType": null, "childrenButtonType": null }, { "id": 29, "parentId": 0, "type": 1, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 2, "menuLevel": null, "hasPower": true, "childrenMenuType": null, "childrenButtonType": [ { "id": 30, "parentId": 29, "type": 2, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 0, "menuLevel": null, "hasPower": null, "childrenMenuType": null, "childrenButtonType": null }, { "id": 31, "parentId": 29, "type": 2, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 0, "menuLevel": null, "hasPower": null, "childrenMenuType": null, "childrenButtonType": null } ] }, { "id": 32, "parentId": 0, "type": 1, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 3, "menuLevel": null, "hasPower": null, "childrenMenuType": null, "childrenButtonType": null }, { "id": 33, "parentId": 0, "type": 1, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 4, "menuLevel": null, "hasPower": null, "childrenMenuType": null, "childrenButtonType": [ { "id": 35, "parentId": 33, "type": 2, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 0, "menuLevel": null, "hasPower": null, "childrenMenuType": null, "childrenButtonType": null } ] }, { "id": 34, "parentId": 0, "type": 1, "name": "xxx.xxx", "remarks": "xxx.xxx", "sourceType": 2, "menuSort": 5, "menuLevel": null, "hasPower": null, "childrenMenuType": null, "childrenButtonType": null } ], "childrenButtonType": null }}
根据角色id可以构建一棵该角色所拥有的资源权限树,返回给前端遍历展示菜单权限树,用于重新给该角色勾选菜单权限,然后将勾选的资源id和角色id写入到角色所拥有的资源权限表-role_resource_power表中,勾选的资源id可以通过跟数据库里面求一个交并补集来实现重新设置新的权限,比如说roleId为1的角色,现在从数据库查出来的资源id是[1,2,3],后面重新勾选授权前端传给厚端的资源id集合是[3,4,5],两次操作3这个资源没有变,之前的拥有的1,2资源权限删除,4,5新给的权限插入即可,这种一操作就达到了给加色授权的目的,其它的管理操作都是CRUD了。


6.登录实现

package xxx.controller;
import cn.dev33.satoken.stp.SaLoginConfig;import cn.dev33.satoken.stp.SaLoginModel;import cn.dev33.satoken.stp.SaTokenInfo;import cn.dev33.satoken.stp.StpUtil;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import lombok.extern.slf4j.Slf4j;import org.apache.commons.lang3.StringUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;
import java.util.List;import java.util.Objects;
@Slf4j@RestController@RequestMapping("/user")public class AdminController {

//TODO 系统登录和登出 //TODO 改为POST请求 //登录 @RequestMapping("/login") public RestResponse login(String account, String pwdCipherText, Integer isRememberMe) { //TODO 可以加上登录失败次数校验,错误次数存redis中 //TODO 密码加密,这里使用MD5加密,后台分配管理员设置账号时需要存储明文和加密密文,这里取的是加密密文对比 //TODO 或者可以加入一个生成验证码验证,提供一个获取验证码的接口给前端,生成后输入验证码验证登录,可以防止接口被刷的风险 if (StringUtils.isBlank(account) || StringUtils.isBlank(pwdCipherText)) { return RestResponse.fail("登录账号或密码不为空!"); } try { //TODO 根据account、Md5加密密码pwdCipherText查询User/admin User user = xxxx if (Objects.isNull(user)) { return RestResponse.fail("登录账号不存在!"); } if ((StringUtils.isNotBlank(user.getAccount()) && !user.getAccount().equals(account)) || (StringUtils.isNotBlank(user.getCipherText()) && !user.getCipherText().equals(pwdCipherText))) { return RestResponse.fail("登录账号或密码不为正确!"); } // 记住我--->`SaLoginModel`为登录参数Model,其有诸多参数决定登录时的各种逻辑 SaLoginModel saLoginModel = SaLoginConfig .setDevice("PC") // 此次登录的客户端设备类型, 用于[同端互斥登录]时指定此次登录的设备类型 .setIsLastingCookie(true) // 是否为持久Cookie(临时Cookie在浏览器关闭时会自动删除,持久Cookie在重新打开后依然存在) .setIsWriteHeader(false); // 是否在登录后将 Token 写入到响应头 if (Objects.nonNull(isRememberMe) && isRememberMe.intValue() == 1) { // 指定此次登录token的有效期, 单位:秒 (如未指定,自动取全局配置的 timeout 值),全局的timeout设置的是1天,记住我设置的是7天 saLoginModel.setTimeout(60 * 60 * 24 * 7); } //加入权限和角色 List roleList = StpUtil.getRoleList(admin.getId()); saLoginModel.setExtra("roles", roleList); List permissionList = StpUtil.getPermissionList(admin.getId()); saLoginModel.setExtra("permissions", permissionList); //这里的id是admin的id主键 StpUtil.login(admin.getId(), saLoginModel); SaTokenInfo tokenInfo = StpUtil.getTokenInfo(); return RestResponse.success(tokenInfo); } catch (Exception e) { log.error("企业会员外部系统登录异常:{}", e.getMessage()); if (StringUtils.isBlank(e.getMessage())) { return RestResponse.fail("登录失败"); } return RestResponse.fail("登录失败:{}", e.getMessage()); } }

// 查询登录状态 请求头带上login的satoken的值 Bearer XXXXXXX @RequestMapping("/isLogin") public RestResponse isLogin() { return RestResponse.success("是否登录:" + StpUtil.isLogin()); }
//TODO 改为POST请求 //注销 请求头带上login的satoken的值 Bearer XXXXXXX @RequestMapping("/logout") public RestResponse logout() { StpUtil.logout(); return RestResponse.success(); }
}
登录的雏形基本已经实现了,如果你对访问接口的安全性有要求还可以使用sa-token的一个很好的功能:API 接口参数签名
https://sa-token.cc/v/v1.36.0/doc.html#/plugin/api-sign
使用该功能让你写的系统安全性更高。



7.总结

由于最近在写一个项目,涉及到后台管理登录管理等功能,所以就构思了基于RBAC角色资源访问控制设计实现了菜单权限的控制,控制权限可以精确到按钮级别,然后接触了sa-token的这个国产开源框架,加入了社区交流群和参看官方文档(仔细看才不会遗漏任何一句有用的话),将项目源码拉下来大概的翻了一下,实现的还是挺优雅的,在项目中集成使用让你的登录功能、菜单权限功能的实现更加优雅,代码量更少,只需要按需引入依赖,简单配置即可优雅实现功能,让开发人员只需要去关注解决业务问题即可,相比于Spring Security+OAuth2来实现登录认证来说,代码量更少、更简单,还有一个开源项目值得更大家分享,JustAuth开箱即用的整合第三方登录的开源组件
https://www.justauth.cn/
有兴趣的可以去看一看,在集成使用sa-token的时候会遇到的问题:
1.跨越问题:上面有解决方法。
2.项目中集成了fegin方式的接口调用,fegin接口调用说白了其实本质还是一个http请求,所以会被sa-token拦截根据uri校验,所以只需要将fegin的接口的顶级uil路径写入到SaServletFilter().addExclude("/xxxx/xxxx")或者在SaServletFilter().setAuth(obj -> {SaRouter.notMatch(“/xxxxx/xxxx”)})里面即可放行。
3.集成了sa-token-spring-aop使用@SaIgnore注解不生效,这个问题正常集成是没有啥问题的,不生效估计是项目依赖有冲突导致不成效,所以可以使用如下办法:

将加了@SaIgnore的请求方法路径解析为一个List 设置在SaServletFilter().addExclude("/xxxx/xxxx")或者在SaServletFilter().setAuth(obj -> {SaRouter.notMatch(“/xxxxx/xxxx”)})里面即可放行,上面是一个sa-token的群友写的,我觉得写的还是可以的,拿来分享下。
3.解决反向代理 uri 丢失的问题
https://sa-token.cc/v/v1.36.0/doc.html#/fun/curr-domain
4.其它问题:参看官方的常见问题排查
https://sa-token.cc/v/v1.36.0/doc.html#/more/common-questions
到此我分享就结束了,希望能对你有所启发和帮助,请一键三连,么么么哒!


请使用浏览器的分享功能分享到微信等