微服务架构使用sa-Token的RABC权限控制实现

satoken 不光可以做权限认证,还可以实现对请求的角色身份做校验,准确来说就是RABC那一套东西。

它的核心代码部分主要集中在登录、网关层部分,其核心就在于在网关配置 satoken 拦截器,如果角色权限不满足则在网关层就可以做拦截处理。

代码实现

建议先看 微服务权限解决方案:基于Nacos+Gateway+Sa-Token
它是本文的简单实现,更为精简。

用过SpringSecurity的都知道,通常我们会写一个配置类,来做请求链的处理,Sa-Token也是如此

Sa-Token本身是不做登录业务的具体校验,它是在登陆成功之后,将当前userId(唯一标识用户)通过 StpUtil.login(userId) 来返回一个Token,最后封装类型为Sa-TokenInfo。

这个Token和JWT是一致的,下次调用的时候我们需要将其设置在请求头中(设置cookie的名称在配置文件中定义),Sa-Token的会话机制,它是采用 Token 作为会话的标识,类似于Web 会话管理的SessionId,只不过这里的Session 是通过 Token 来标识的(Token由每个用户成功登录后进行分配),其实也很好理解,传统的JWT因为是无状态,所以在实现登出、注销的功能时比较麻烦,而Session来处理就方便的多,Token 失效,Session 也随之失效。Sa-Token本身就是结合JWT和Session两大机制的优点,后面可以很方便的通过StpUtil.getSession() 获取存放在Session的用户信息。

Sa-Token的配置参考:

sa-token:
  # token名称 (同时也是cookie名称)
  token-name: Authorization
  # token有效期,单位秒,-1代表永不过期
  timeout: 2592000
  # token临时有效期 (指定时间内无操作就视为token过期),单位秒
  active-timeout: -1
  # 是否允许同一账号并发登录 (为false时新登录挤掉旧登录)
  is-concurrent: true
  # 在多人登录同一账号时,是否共用一个token (为false时每次登录新建一个token)
  is-share: false
  # token风格
  token-style: uuid
  # 是否输出操作日志
  is-log: true
  # 是否从cookie中读取token
  is-read-cookie: false
  # 是否从head中读取token
  is-read-header: true
  # token前缀
  token-prefix: Bearer
  # 是否打印banner
  is-print: false

根据业务场景来设置,开发的时候建议打开is-log,如果是微服务架构则建议是使用从head中读token(每次请求携带),cookie更适合做单体项目
注意token-name,在测试的时候是在请求头中添加对应参数,而不是在Auth中进行添加!

image.png

satoken在实现角色控制资源的功能,它是需要修改源代码做点小改动的,主要是配合业务逻辑处理,这一点先按下不表,后面具体会讲到。

这是原方法getPermissionList,可以看原来的方法中只是返回一个新的list

public class StpInterfaceDefaultImpl implements StpInterface {
    public StpInterfaceDefaultImpl() {
    }

    public List<String> getPermissionList(Object loginId, String loginType) {
        return new ArrayList();
    }

    public List<String> getRoleList(Object loginId, String loginType) {
        return new ArrayList();
    }
}
  • loginId: 用户id
  • loginType:标识操作类型,用于区分不同类型的登录账号体系,例如一个页面有前台(用户端)和后台(管理端),通过这个参数可以判断当前登录用户是后台管理员还是前台用户。

这里的loginType是由登陆的时候决定,用户通过后台系统登录时,loginType 被设为 “admin”;当一个用户通过前台网站登录时,loginType 被设为 “user”,默认情况下,loginType 的值是 default,即没有设置时系统会认为这是默认的登录体系。

具体一点的我们会有这样一个模块–认证服务,它可能是由第三方平台提供,这里示例就是单独对前端和后端页面进行分开认证处理,具体的登陆逻辑写在对应模块中的,通过openfeign来联系:

@Controller
@Tag(name = "AuthController", description = "统一认证授权接口")
@RequestMapping("/auth")
public class AuthController {

    @Autowired
    private UmsAdminService adminService;

    @Autowired
    private UmsMemberService memberService;

    @Operation(summary = "登录以后返回token")
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public CommonResult login(@RequestParam String clientId,
                              @RequestParam String username,
                              @RequestParam String password) {
        if(AuthConstant.ADMIN_CLIENT_ID.equals(clientId)){
            UmsAdminLoginParam loginParam = new UmsAdminLoginParam();
            loginParam.setUsername(username);
            loginParam.setPassword(password);
            return adminService.login(loginParam);
        }else if(AuthConstant.PORTAL_CLIENT_ID.equals(clientId)){
            return memberService.login(username,password);
        }else{
            return CommonResult.failed("clientId不正确");
        }
    }
}

这里展示后台管理页面的登陆逻辑,它的loginType是默认的,没有显式指定,具体的 loginType 是通过 StpUtil 类内部关联的账号体系决定的

 @Override
    public SaTokenInfo login(String username, String password) {
        if(StrUtil.isEmpty(username)||StrUtil.isEmpty(password)){
            Asserts.fail("用户名或密码不能为空!");
        }
        UmsAdmin admin = getAdminByUsername(username);
        if(admin==null){
            Asserts.fail("找不到该用户!");
        }
        if (!BCrypt.checkpw(password, admin.getPassword())) {
            Asserts.fail("密码不正确!");
        }
        if(admin.getStatus()!=1){
            Asserts.fail("该账号已被禁用!");
        }
        // 登录校验成功后,一行代码实现登录
        StpUtil.login(admin.getId());
        UserDto userDto = new UserDto();
        userDto.setId(admin.getId());
        userDto.setUsername(admin.getUsername());
        userDto.setClientId(AuthConstant.ADMIN_CLIENT_ID);
        List<UmsResource> resourceList = getResourceList(admin.getId());
        List<String> permissionList = resourceList.stream().map(item -> item.getId() + ":" + item.getName()).toList();
        userDto.setPermissionList(permissionList);
        // 将用户信息存储到Session中
        StpUtil.getSession().set(AuthConstant.STP_ADMIN_INFO,userDto);
        // 获取当前登录用户Token信息
        SaTokenInfo saTokenInfo = StpUtil.getTokenInfo();
//        updateLoginTimeByUsername(username);
        insertLoginLog(admin);
        return saTokenInfo;
    }

回到上面要改写的方法中,在这里就可以通过判断 loginType(登陆的时候传入,该方法也是satoken底层调用) ,这里的业务背景只有在登陆后端页面才会获取角色权限信息,所以这里判断的是 loginType是否是后端页面,是则通过标识符取Session返回角色信息。

@Component
public class StpInterfaceImpl implements StpInterface {

  @Override
public List<String> getPermissionList(Object loginId, String loginType) {
    // 获取当前登录的用户类型
    String currentLoginType = StpUtil.getSession().get("loginType", String.class);

    // 判断当前登录类型和传入的loginType是否一致
    if (currentLoginType.equals(loginType)) {
        if (loginType.equals("admin")) {
            // 返回后台用户的权限列表
            UserDto userdto = (UserDto) StpUtil.getSession().get(AuthConstant.STP_ADMIN_INFO);
            return userdto.getPermissionList();
        } else {
            // 返回前台用户的权限列表
            UserDto userdto = (UserDto) StpUtil.getSession().get(AuthConstant.STP_USER_INFO);
            return userdto.getPermissionList();
        }
    } else {
        return null; // 用户类型不匹配,返回空列表
    }
}


    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 返回此 loginId 拥有的角色码列表
        return null;
    }

RABC模型讲解

如果在面对多租户的模式,一个后台管理页面可能入驻商家,而商家能看到的模块内容也是需要进行控制,这里的资源分为两种:前端页面、后端接口,前端页面很好理解,即没有对应的权限的用户根本就不能看到该模块的信息,对应的一个前端页面包含多个接口信息,这些也是需要判断权限才能调用的,实现形式的关键就在于建表上。

我们将角色role作为控制资源的操作者,它绑定前端页面、后端接口资源,它们都是1对多的关系,这样我们只需要知道roleId,就可以得知它可以访问哪些前端资源、后端接口资源,那么roleId又是如何得来的呢,role是和当前登录用户绑定的,这个是在注册或登录的时候就已经绑定了。

RABC可以是通过role来关联包括菜单、权限、资源,而后端只需要能通过userId,获取该用户对应的角色,之后操作都是基于roleId来查询

下面我们以具体的表来说明关联:

admin_role_relation

image.png

admin_id由登陆的时候获取,也就是userId(主键id),以admin表中id=4,对应admin_role_relation,也就是roleId=5

image.png

roleId_permission

接下来查询roleId_permission表,获取当前角色具体的权限

image.png

可以查到roleId=5,怎么找不到相关信息?这里是因为我们恰好拿的是系统管理员,而系统管理员它具有所有权限,我们单独来做处理,也避免了每次还要慢慢的查表。

回到第一张表,我们选择adminId=6,对应roleId=1,这里我们也不妨看看它对应的是什么角色:

role

image.png

原来是管理商品的,那么它对应的权限、资源也应该是商品相关的,我们看是不是这样呢?

根据roleId_permission表,可以知道对应的permissionId由:[1,2,3,7,8]

permission

依次查看permission表,看看他们对应的是什么:

image.png

也就是[商品,商品列表,添加商品,编辑商品,删除商品]

对应的也就是crud,那第一个商品对应是什么含义呢?

首先需要理解这里permission表的各个字段含义:

  • pid(父id):它指向的是主键id,以所有pid=2为例,它们分别对应主键id=2(商品列表),这里是按照业务逻辑来划分的,首先商品是已经存在了,才可以做编辑和删除。同理其他的也是一致。
  • url:权限都是需要绑定实体,才能进行控制,这里的实体就是前端的页面资源,它标识一个功能模块需要的界面,这决定用户可以看多少内容
  • type:type是为了说明当前url在功能模块代表什么类型的资源,它是辅助前端展示,例如这个是目录(点击菜单展开所有功能),也可以是按钮(点击按钮执行具体功能)
    • 0->目录;1->菜单;2->按钮
  • status:权限启用状态

回到主流程中,我们拿到permissions,可以知道了该角色可以访问哪些前端页面,准确来说如果不涉及到这些前端页面上其他的操作(对应后端不同接口),就已经可以实现一个指定权限用户访问部分页面的功能了。

接下来就是对后端接口的权限限制了,前端我们设置的资源是一个页面为单位,在后端我们设置的资源则是以一个接口为单位。

上面我们拿到的permission是和商品相关的,所以正常来说我们是能访问商品相关的页面,那么对应一个页面的功能接口我们也应该是能正常访问的,例如我点击编辑商品,此时需要跳转到编辑商品页面(/pms/product/updateProduct),这个页面符合permission管控的页面,可以正常显示。然后我填写完要改写的数据,点击更新,此时就要调用后端的接口了,这里就又涉及到权限了。

这里我们想知道当前操作人是否有权限操作编辑接口,按照命名规则感觉也是要去查permission表,但permission定义的资源是前端页面,查它就没有意义了(数据库设计,当然你说同一张permission表同时存后端、前端资源行不行呢,也是可行的,但前端页面和后端接口又是1对多的关系,就有些麻烦了),所以这里实际上是要去role_resource表中去查:

role_resource

image.png

还是以roleId=1为例,这里对应的resources:[32,31,24,23,6,5,4,3,2,1]

resource

同样我们可以去resource中具体看是什么:

image.png

最右边还有一个字段hidden,0-前端不隐藏,1-前端隐藏

category_id 对应的是resource_category 中的数据,作为外键:

resource_category:

为什么不跟上面的表合并为一张呢,而是单独设计一张表呢?主要还是为了方便后面维护,新增、删除模块都只是关联修改的问题。

image.png

我们现在只关心当前操作人是否有权限操作编辑商品接口,我们查resource表可以看到 resourceId=3 它就是包括所有商品属性管理,对应的接口为/productAttribute/**,肯定是满足的啦。

至此关于数据库的逻辑流程我们知道了,代码本质上也是按照这个流程来进行判断权限的,但在代码中肯定是不能每次都查数据库,这样性能开销很大,因为基本上所有请求都是需要做权限校验的,所以最好是将这种资源对应的权限关系存入内存或者是redis中。

这里我们采用的Sa-Token来实现RABC,我们先按照配置Sa-Token流程走:

Sa-Token拦截器配置

在后端的逻辑就是,请求(request)会经过网关,因为这里是由Sa-Token来实现,网关的请求处理链实际上是由satoken配置类中指定来做,也就是下面这个配置:

@Configuration
public class SaTokenConfig {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 注册Sa-Token全局过滤器
     */
    @Bean
    public SaReactorFilter getSaReactorFilter(IgnoreUrlsConfig ignoreUrlsConfig) {
        return new SaReactorFilter()
                // 拦截地址
                .addInclude("/**")
                // 配置白名单路径
                .setExcludeList(ignoreUrlsConfig.getUrls())
                // 鉴权方法:每次访问进入
                .setAuth(obj -> {
                    // 对于OPTIONS预检请求直接放行
                    SaRouter.match(SaHttpMethod.OPTIONS).stop();
                    // 登录认证:商城前台会员认证
                    SaRouter.match("/mall-portal/**", r -> StpMemberUtil.checkLogin()).stop();
                    // 登录认证:管理后台用户认证
                    SaRouter.match("/mall-admin/**", r -> StpUtil.checkLogin());
                    // 权限认证:管理后台用户权限校验
                    // 获取Redis中缓存的各个接口路径所需权限规则
                    Map<Object, Object> pathResourceMap = redisTemplate.opsForHash().entries(AuthConstant.PATH_RESOURCE_MAP);
                    // 获取到访问当前接口所需权限(一个路径对应多个资源时,拥有任意一个资源都可以访问该路径)
                    List<String> needPermissionList = new ArrayList<>();
                    // 获取当前请求路径
                    String requestPath = SaHolder.getRequest().getRequestPath();
                    // 创建路径匹配器
                    PathMatcher pathMatcher = new AntPathMatcher();
                    // 遍历所有路径规则
                    Set<Map.Entry<Object, Object>> entrySet = pathResourceMap.entrySet();
                    for (Map.Entry<Object, Object> entry : entrySet) {
                        String pattern = (String) entry.getKey();
                        if (pathMatcher.match(pattern, requestPath)) {
                            needPermissionList.add((String) entry.getValue());
                        }
                    }
                    // 接口需要权限时鉴权
                    if(CollUtil.isNotEmpty(needPermissionList)){
                        SaRouter.match(requestPath, r -> StpUtil.checkPermissionOr(Convert.toStrArray(needPermissionList)));
                    }
                })
                // setAuth方法异常处理
                .setError(this::handleException);
    }

  • 拦截所有路径并校验是否存在于白名单
  • 对访问请求进行鉴权处理
    • 预检请求处理
    • 登陆位置处理(前台页面、后台页面),对应两套不同的校验逻辑,后台更严,前台不登陆也能访问
      • 校验请求是否携带token(未携带代表未登录)
    • 校验当前请求路径是否符合管控的后端资源(也就是上面说的存放于Redis的权限资源)
    • 校验当前用户是否有权限访问后端资源(成功则路由转发)

后面两点有点不好理解,为什么和Redis还有关联呢?前面说了每次查数据库很麻烦,所以会引用Redis来作为第三方存储,避免每次都慢慢查表,再说网关也经不住怎么慢慢查啊。

首先需要确定是什么时候放入redis的?

通常来说像这类配置文件,规则其实最好都是在spring启动之后就开始初始化,可见:

/**
 * 路径与资源访问对应关系操作组件
 */
@Component
public class PathResourceRulesHolder {

    @Autowired
    private UmsResourceService resourceService;

    // 接口资源规则初始化
    @PostConstruct
    public void initPathResourceMap(){
        resourceService.initPathResourceMap();
    }
}

这里使用@PostConstruct注解(通常标注为方法),代表它在UmsResourceService注入完毕之后自动执行该方法

@PostConstruct 注解的方法会在构造函数调用后、依赖注入(如 @Autowired)完成后自动执行。

  • 初始化逻辑:在类实例化后,进行一些初始化工作,比如初始化某个缓存、执行某些配置任务等。
  • 资源准备:加载资源文件、连接外部服务(如数据库、第三方API等)。
  • 验证和检查:在对象构建完成后进行数据的校验或检查,确保对象的状态是合法的。

具体的逻辑是:

/**
     * 将所有的资源路径与资源信息的映射关系存入 Redis 中
     * @return {@link Map }<{@link String },{@link String }>
     */
    @Override
    public Map<String,String> initPathResourceMap() {
        Map<String,String> pathResourceMap = new TreeMap<>();
        // 资源(接口)
        List<UmsResource> resourceList = resourceMapper.selectByExample(new UmsResourceExample());
        for (UmsResource resource : resourceList) {
            pathResourceMap.put("/"+applicationName+resource.getUrl(),resource.getId()+":"+resource.getName());
        }
        // 存入redis
        redisService.del(AuthConstant.PATH_RESOURCE_MAP);
        redisService.hSetAll(AuthConstant.PATH_RESOURCE_MAP, pathResourceMap);
        return pathResourceMap;

    }

这里先查询resource表获取所有接口信息,以一个Map的形式存放,其key为接口路径,其value为拼接的字符串(id:xxxname),形式就是这样的:

在这里插入图片描述

回到配置文件上,因为是在网关层是通过获取request的路径,将其与Map的key进行匹配,Map中包含所有的接口路径,这里是与该路径匹配成功,则将对应的resourceId:name(例如1:商品品牌管理)的字符串存放在needPermissionList中后续使用。

Set<Map.Entry<Object, Object>> entrySet = pathResourceMap.entrySet();
for (Map.Entry<Object, Object> entry : entrySet) {
    String pattern = (String) entry.getKey();
    if (pathMatcher.match(pattern, requestPath)) {
        needPermissionList.add((String) entry.getValue());
    }
}

// stream写法
needPermissionList = pathResourceMap.entrySet().stream()
.filter(entry -> new AntPathMatcher().match((String) entry.getKey(), requestPath))
.map(entry -> (String) entry.getValue()).collect(Collectors.toList());

//forEach写法
pathResourceMap.entrySet().forEach(entry -> {
                        String pattern = (String) entry.getKey();
                        if (pathMatcher.match(pattern, requestPath)) {
                            needPermissionList.add((String) entry.getValue());
                        }
                    })

当前路径能够正常匹配Map中的元素,通常来说是肯定的,因为Map中存放的是所有接口路径,needPermissionList中包含的元素实际上就是resourceId,而这里我们下一步需要处理的就是当前用户对应的role,是否支持对该资源的访问,这里是直接用的satoken的api方法,这也解释了为什么之前要修改其底层方法。

  if(CollUtil.isNotEmpty(needPermissionList)){
                        SaRouter.match(requestPath, r -> StpUtil.checkPermissionOr(Convert.toStrArray(needPermissionList)));
                    }
                })

SaRouter是satoken的路由导航,拦截指定的请求路径并设置对应路由匹配规则,这里就是对当前的resourceId进行权限校验。

重点看看checkPermissionOr是什么:

public void checkPermissionOr(String... permissionArray) {
        Object loginId = this.getLoginId();
        if (permissionArray != null && permissionArray.length != 0) {
            List<String> permissionList = this.getPermissionList(loginId);
            String[] var4 = permissionArray;
            int var5 = permissionArray.length;

            for(int var6 = 0; var6 < var5; ++var6) {
                String permission = var4[var6];
                if (this.hasElement(permissionList, permission)) {
                    return;
                }
            }

            throw (new NotPermissionException(permissionArray[0], this.loginType)).setCode(11051);
        }
    }
    
    public List<String> getPermissionList(Object loginId) {
        return SaManager.getStpInterface().getPermissionList(loginId, this.loginType);
    }

首先就是获取当前用户的userId,然后根据userId去调用getPermissionList获取权限列表,是不是很熟悉,这个方法正是我们前面改写的方法:

    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 返回此loginId拥有的权限码列表
        if(StpUtil.getLoginType().equals(loginType)){
            //后台用户需返回
            UserDto userdto = (UserDto) StpUtil.getSession().get(AuthConstant.STP_ADMIN_INFO);
            return userdto.getPermissionList();
        }else{
            //前台用户无需返回
            return null;
        }
    }

其核心逻辑就是按照在登录时存取的标识符(标识前台还是后台页面),去取session中的用户信息,这里我们可以看下用户信息包含哪些东西:

@Data
@EqualsAndHashCode(callSuper = false)
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {
    private Long id;
    private String username;
    private String clientId;
    private List<String> permissionList;
}

这个permissionList就是我们在登录的时候存入的,可以看:

  @Override
    public SaTokenInfo login(String username, String password) {
        if(StrUtil.isEmpty(username)||StrUtil.isEmpty(password)){
            Asserts.fail("用户名或密码不能为空!");
        }
        UmsAdmin admin = getAdminByUsername(username);
        if(admin==null){
            Asserts.fail("找不到该用户!");
        }
        if (!BCrypt.checkpw(password, admin.getPassword())) {
            Asserts.fail("密码不正确!");
        }
        if(admin.getStatus()!=1){
            Asserts.fail("该账号已被禁用!");
        }
        // 登录校验成功后,一行代码实现登录
        StpUtil.login(admin.getId());
        UserDto userDto = new UserDto();
        userDto.setId(admin.getId());
        userDto.setUsername(admin.getUsername());
        // 设置客户端ID--标识前台还是后台,因为两者登录处理逻辑不一样
        userDto.setClientId(AuthConstant.ADMIN_CLIENT_ID);
        // 获取当前登录用户拥有的资源权限
        List<UmsResource> resourceList = getResourceList(admin.getId());
        List<String> permissionList = resourceList.stream().map(item -> item.getId() + ":" + item.getName()).toList();
        userDto.setPermissionList(permissionList);
        // 将用户信息存储到Session中
        StpUtil.getSession().set(AuthConstant.STP_ADMIN_INFO,userDto);
        // 获取当前登录用户Token信息
        SaTokenInfo saTokenInfo = StpUtil.getTokenInfo();
//        updateLoginTimeByUsername(username);
        insertLoginLog(admin);
        return saTokenInfo;
    }

至此关于在微服务架构中使用satoken做权限认证、接口路径权限访问就完结了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值