weblog/doc/7、Gateway 网关搭建与接口鉴权/7.4 网关整合 SaToken 实现接口鉴权(2).md
2025-02-17 11:57:55 +08:00

24 KiB
Raw Blame History

在前面小节中,我们对网关中的 SaToken ,配置了拦截所有路由,都需要进行登录校验,仅排除了登录接口,以及获取验证码接口。但是,光是有登录校验肯定不行的,还需要进行权限校验,比如普通用户即使登录了,也无法执行管理员独有的一些操作。

配置接口鉴权

那么,在本小节中,我们继续完善网关接口的鉴权功能。这里为了测试,如下图所示,针对用户登出接口,假设除了要进行登录校验外,还需要校验该用户是否拥有 user 权限:

代码如下:

SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("user"));

Tip

: StpUtil.checkPermission("user")) 的入参 user 代表的是权限标识字符串,即咱们权限表中定义的 permission_key 列, 如下图所示:

以上接口鉴权配置完成后,重启网关服务,测试一波登出接口,记得请求头中携带上令牌,如下图所示。由于目前咱们的用户还没有 user 权限,网关会提示:无此权限user :

到这里,说明网关服务中 SaToken 的接口鉴权功能,已经是正常工作了。

StpInterface 接口实现类说明

问题来了,SaToken 中,是怎么获取用户的角色和权限数据的?

在 SaToken 框架中,是通过 StpInterface 的实现类来拉取相关数据的,即之前创建的 StpInterfaceImpl 类。

添加依赖

为了验证,我们在 StpInterfaceImpl 类中打印一些日志,来看看效果。为了能够使用 Lombok 的 @Slf4j 日志注解,编辑 xiaohashu-gateway 网关的 pom.xml 文件,添加通用模块的依赖,如下,该模块中已添加 Lombok 的依赖:

Tip

: 依赖具有传递性,子模块中已经添加的依赖,父模块中也能直接使用。

        <dependency>
            <groupId>com.quanxiaoha</groupId>
            <artifactId>xiaoha-common</artifactId>
        </dependency>

依赖添加完成后,重新刷新一下 Maven 依赖。

打印日志

编辑 StpInterfaceImpl 类,添加一些日志,看看 SaToken 在权限校验时,具体执行了哪个方法,代码如下:

package com.quanxiaoha.xiaohashu.gateway.auth;

import cn.dev33.satoken.stp.StpInterface;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;

/**
 * @author: 犬小哈
 * @date: 2024/4/5 18:04
 * @version: v1.0.0
 * @description: 自定义权限验证接口扩展
 **/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {

    /**
     * 获取用户权限
     *
     * @param loginId
     * @param loginType
     * @return
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 返回此 loginId 拥有的权限列表
        log.info("## 获取用户权限列表, loginId: {}", loginId);

        // todo 从 redis 获取

        return Collections.emptyList();
    }

    /**
     * 获取用户角色
     *
     * @param loginId
     * @param loginType
     * @return
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        log.info("## 获取用户角色列表, loginId: {}", loginId);

        // 返回此 loginId 拥有的角色列表
        // todo 从 redis 获取
        
        return Collections.emptyList();
    }

}

  • getPermissionList() : 获取用户权限列表,要求返参是 List<String> 字符串集合,数据格式类似如 ["app:note:publish", "app:comment:publish"] ;
  • getRoleList(): 获取用户角色列表,要求返参是 List<String> 字符串集合,数据格式类似如 ["common_user", "admin"] ;

再次重启网关服务,测试登出接口,观察控制台日志打印:

可以看到,由于咱们针对登出接口,配置的是 checkPermission("user") , 检查是否拥有 user 标识符的权限。SaToken 实际上会主动调用 StpInterfaceImpl.getPermissionList() 方法,去查询当前用户实际拥有的权限集合,并与之做对比来做判断,入参 loginId 即用户 ID。由于咱们这个方法里面目前返回的是空集合即判为无此权限。同理,如何你配置的是 checkRole() , 则会调用 StpInterfaceImpl.getRoleList() 获取角色列表方法。

修改 Redis 中角色-权限数据格式

了解完 SaToken 鉴权执行流程后,我们需要修改一下 Redis 中存储的角色-权限数据格式。

角色

首先是角色,如下图所示,之前,当时用户注册成功后,存储到 Redis 中用户-角色数据的key,是通过手机号来区分的。而目前网关会把 Token 解析为用户 ID, 这就对应不起来了,所以要修正一下。

编辑 xiaohashu-auth 认证服务中的 RedisKeyConstants 常量类,修改 buildUserRoleKey() 方法,改为拼接用户 ID:

package com.quanxiaoha.xiaohashu.auth.constant;

public class RedisKeyConstants {

	// 省略...

    /**
     * 用户对应的角色集合 KEY
     * @param userId
     * @return
     */
    public static String buildUserRoleKey(Long userId) {
        return USER_ROLES_KEY_PREFIX + userId;
    }

	// 省略...
}

除了 key 值外,value 值也修改一下,目前我们存储的是,权限 ID 集合,如下:

"[1]"

为了方便后续维护,以及网关服务查询,将其修改为字符串标识,如下:

["common_user", "admin"]

开始编码

清楚要做的事情后,编辑认证服务中 UserServiceImpl 业务类中,用户自动注册的逻辑,修改代码如下:

package com.quanxiaoha.xiaohashu.auth.service.impl;

import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.quanxiaoha.framework.common.enums.DeletedEnum;
import com.quanxiaoha.framework.common.enums.StatusEnum;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.enums.LoginTypeEnum;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

@Service
@Slf4j
public class UserServiceImpl implements UserService {
    // 省略..
    
    @Resource
    private RoleDOMapper roleDOMapper;
 	
 	// 省略..

    /**
     * 系统自动注册用户
     * @param phone
     * @return
     */
    private Long registerUser(String phone) {
        return transactionTemplate.execute(status -> {
            try {
                // 省略..

                // 给该用户分配一个默认角色
                // 省略...

                RoleDO roleDO = roleDOMapper.selectByPrimaryKey(RoleConstants.COMMON_USER_ROLE_ID);

                // 将该用户的角色 ID 存入 Redis 中,指定初始容量为 1这样可以减少在扩容时的性能开销
                List<String> roles = new ArrayList<>(1);
                roles.add(roleDO.getRoleKey());

                String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
                redisTemplate.opsForValue().set(userRolesKey, JsonUtils.toJsonString(roles));

                return userId;
            } catch (Exception e) {
				// 省略...
            }
        });
    }

}

自测一波

修改完毕后,我们来自测一波。先将 Redis 中 user:roles: 缓存删除掉:

另外,将 t_user 用户表,以及 t_user_role_rel 用户角色关联表中的数据删除掉,确保请求登录接口时,当前手机号是个未注册的用户,以推送新的用户-角色数据到 Redis 中。测试后,观察 Redis 中的数据是否符合预期的格式:

权限

角色改了,权限也需要适配一下。如下图所示,目前角色-权限的 key 格式是通过角色 ID 来区别的,需要改成角色唯一标识,如下:

role:permissions:common_user

另外,当前 value 存储的是权限实体类集合。这样会导致网关层查询的时候,非常不方便。

如果直接存储的是权限标识的字符串集合,如下所示,这样数据查出来就能用,无需再额外提取 permissionKey 字段值。性能上也高很多。

["app:note:publish", "app:comment:publish"]

开始编码

编辑认证服务中的 RedisKeyConstants 常量类,修改 buildRolePermissionsKey() 方法,通过角色唯一标识符来拼接 Redis 的 key , 代码如下:

package com.quanxiaoha.xiaohashu.auth.constant;

public class RedisKeyConstants {

    // 省略...

    /**
     * 构建角色对应的权限集合 KEY
     * @param roleKey
     * @return
     */
    public static String buildRolePermissionsKey(String roleKey) {
        return ROLE_PERMISSIONS_KEY_PREFIX + roleKey;
    }
}

最后,PushRolePermissions2RedisRunner 启动任务中,推送角色-权限数据到 Redis 中的逻辑,也需要适配一下,修改的地方我都标注出来了:

代码如下:

				//  省略..
				
				// 组织 角色-权限 关系
                Map<String, List<String>> roleKeyPermissionsMap = Maps.newHashMap();

                // 循环所有角色
                roleDOS.forEach(roleDO -> {
                    // 当前角色 ID
                    Long roleId = roleDO.getId();
                    // 当前角色 roleKey
                    String roleKey = roleDO.getRoleKey();
                    // 当前角色 ID 对应的权限 ID 集合
                    List<Long> permissionIds = roleIdPermissionIdsMap.get(roleId);
                    if (CollUtil.isNotEmpty(permissionIds)) {
                        List<String> permissionKeys = Lists.newArrayList();
                        permissionIds.forEach(permissionId -> {
                            // 根据权限 ID 获取具体的权限 DO 对象
                            PermissionDO permissionDO = permissionIdDOMap.get(permissionId);
                            permissionKeys.add(permissionDO.getPermissionKey());
                        });
                        roleKeyPermissionsMap.put(roleKey, permissionKeys);
                    }
                });

                // 同步至 Redis 中,方便后续网关查询 Redis, 用于鉴权
                roleKeyPermissionsMap.forEach((roleKey, permissions) -> {
                    String key = RedisKeyConstants.buildRolePermissionsKey(roleKey);
                    redisTemplate.opsForValue().set(key, JsonUtils.toJsonString(permissions));
                });
                
                //  省略..

自测一波

代码适配完成后,依然是自测一波。将 Redis 中角色-权限缓存先删除掉,以及 push.permission.flag 锁也删除。重启认证服务,等待角色-权限数据推送完成,观察 Redis 中的数据,看看是否是前面定义好的格式:

若如上所示,则 Redis 中角色、权限的基础数据格式就修正完毕了,可以开始正式编写 StpInterfaceImpl 类中,查询鉴权数据的逻辑代码了。

SaToken 查询权限数据

添加依赖

编辑 xiaohashu-gateway 网关服务的 pom.xml 文件,添加如下依赖,因为等会需要查询 Redis 中的权限数据:

        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Jackson 组件 -->
        <dependency>
            <groupId>com.quanxiaoha</groupId>
            <artifactId>xiaoha-spring-boot-starter-jackson</artifactId>
        </dependency>

配置 RedisTemplate

创建 /config 包,并将认证服务中,已经配置好的 RedisTemplateConfig 配置类复制过来:

package com.quanxiaoha.xiaohashu.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author: 犬小哈
 * @date: 2024/4/6 15:51
 * @version: v1.0.0
 * @description: RedisTemplate 配置
 **/
@Configuration
public class RedisTemplateConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        // 设置 RedisTemplate 的连接工厂
        redisTemplate.setConnectionFactory(connectionFactory);

        // 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());

        // 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        redisTemplate.setValueSerializer(serializer);
        redisTemplate.setHashValueSerializer(serializer);

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

Redis Key 全局常量类

创建 /constant 常量包,并新建 RedisKeyConstants 常量类,代码如下:

package com.quanxiaoha.xiaohashu.gateway.constant;

/**
 * @author: 犬小哈
 * @date: 2024/5/21 15:04
 * @version: v1.0.0
 * @description: TODO
 **/
public class RedisKeyConstants {


    /**
     * 用户对应角色集合 KEY 前缀
     */
    private static final String USER_ROLES_KEY_PREFIX = "user:roles:";

    /**
     * 角色对应的权限集合 KEY 前缀
     */
    private static final String ROLE_PERMISSIONS_KEY_PREFIX = "role:permissions:";

    /**
     * 构建角色对应的权限集合 KEY
     * @param roleKey
     * @return
     */
    public static String buildRolePermissionsKey(String roleKey) {
        return ROLE_PERMISSIONS_KEY_PREFIX + roleKey;
    }

    /**
     * 构建用户-角色 KEY
     * @param userId
     * @return
     */
    public static String buildUserRoleKey(Long userId) {
        return USER_ROLES_KEY_PREFIX + userId;
    }
}

查询角色

前置工作完成后,添加 StpInterfaceImplgetRoleList() 方法中,获取当前用户对应的角色集合的逻辑代码:

package com.quanxiaoha.xiaohashu.gateway.auth;

import cn.dev33.satoken.stp.StpInterface;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quanxiaoha.xiaohashu.gateway.constant.RedisKeyConstants;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;

/**
 * @author: 犬小哈
 * @date: 2024/4/5 18:04
 * @version: v1.0.0
 * @description: 自定义权限验证接口扩展
 **/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {

    @Resource
    private RedisTemplate<String, String> redisTemplate;
    @Resource
    private ObjectMapper objectMapper;

	// 省略...

    /**
     * 获取用户角色
     *
     * @param loginId
     * @param loginType
     * @return
     */
    @SneakyThrows
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        log.info("## 获取用户角色列表, loginId: {}", loginId);

        // 构建 用户-角色 Redis Key
        String userRolesKey = RedisKeyConstants.buildUserRoleKey(Long.valueOf(loginId.toString()));

        // 根据用户 ID ,从 Redis 中获取该用户的角色集合
        String useRolesValue = redisTemplate.opsForValue().get(userRolesKey);

        if (StringUtils.isBlank(useRolesValue)) {
            return null;
        }

        // 将 JSON 字符串转换为 List<String> 集合
        return objectMapper.readValue(useRolesValue, new TypeReference<>() {});
    }

}

查询用户对应的角色逻辑比较简单,通过入参中的用户 ID, 直接构建 用户-角色 Redis Key, 查询出数据后,先判空,若为空,直接返回 null; 否则将角色集合数据返回。

自测一波

接下来自测一波。编辑 SaTokenConfigure 配置类,将登出接口的权限校验,修改为校验登录用户是否拥有 admin 角色,如下图所示:

代码如下:

SaRouter.match("/auth/user/logout", r -> StpUtil.checkRole("admin"));

重启网关服务,测试一波登出接口,由于目前用户只有 common_user 普通用户角色,可以看到,返参提示:无此角色admin :

如果你手动修改 Redis 中该用户的角色数据,添加上 admin 角色后,如下图所示:

再次测试登录接口,就能权限校验通过了。测试完后,记得将 Redis 中该用户的 admin 角色删掉哟~

查询权限

接下来,编写查询用户权限的逻辑,即 getPermissionList() 方法中的逻辑,代码如下:

package com.quanxiaoha.xiaohashu.gateway.auth;

import cn.dev33.satoken.stp.StpInterface;
import cn.hutool.core.collection.CollUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import com.quanxiaoha.xiaohashu.gateway.constant.RedisKeyConstants;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;

/**
 * @author: 犬小哈
 * @date: 2024/4/5 18:04
 * @version: v1.0.0
 * @description: 自定义权限验证接口扩展
 **/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {

    @Resource
    private RedisTemplate<String, String> redisTemplate;
    @Resource
    private ObjectMapper objectMapper;

    /**
     * 获取用户权限
     *
     * @param loginId
     * @param loginType
     * @return
     */
    @SneakyThrows
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        log.info("## 获取用户权限列表, loginId: {}", loginId);

        // 构建 用户-角色 Redis Key
        String userRolesKey = RedisKeyConstants.buildUserRoleKey(Long.valueOf(loginId.toString()));

        // 根据用户 ID ,从 Redis 中获取该用户的角色集合
        String useRolesValue = redisTemplate.opsForValue().get(userRolesKey);

        if (StringUtils.isBlank(useRolesValue)) {
            return null;
        }

        // 将 JSON 字符串转换为 List<String> 角色集合
        List<String> userRoleKeys = objectMapper.readValue(useRolesValue, new TypeReference<>() {});

        if (CollUtil.isNotEmpty(userRoleKeys)) {
            // 查询这些角色对应的权限
            // 构建 角色-权限 Redis Key 集合
            List<String> rolePermissionsKeys = userRoleKeys.stream()
                    .map(RedisKeyConstants::buildRolePermissionsKey)
                    .toList();

            // 通过 key 集合批量查询权限,提升查询性能。
            List<String> rolePermissionsValues = redisTemplate.opsForValue().multiGet(rolePermissionsKeys);

            if (CollUtil.isNotEmpty(rolePermissionsValues)) {
                List<String> permissions = Lists.newArrayList();
                
                // 遍历所有角色的权限集合,统一添加到 permissions 集合中
                rolePermissionsValues.forEach(jsonValue -> {
                    try {
                        // 将 JSON 字符串转换为 List<String> 权限集合
                        List<String> rolePermissions = objectMapper.readValue(jsonValue, new TypeReference<>() {});
                        permissions.addAll(rolePermissions);
                    } catch (JsonProcessingException e) {
                        log.error("==> JSON 解析错误: ", e);
                    }
                });

                // 返回此用户所拥有的权限
                return permissions;
            }
        }
        return null;
    }

	// 省略...

}



查询用户权限数据的逻辑,稍微复杂一点,这里解释一下:

  • 通过用户 ID 查询 Redis, 先获取当前用户所有的角色;
  • 若角色集合不为空,再次查询 Redis, 通过 multiGet 方法,一次性将这些角色对应的权限标识符查询出来,保证最少的 IO 次数(与 Redis 只交互 2 次),提升查询性能。最后,统一放到一个 List<String> 集合中,作为返参返回;

自测一波

代码编写完毕后,再次自测一波。为登出接口配置权限校验,校验用户是否拥有 app:note:delete 笔记删除权限,目前所有用户是没有这个权限的:

代码如下:

SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("app:note:delete"));

重启网关服务,测试接口,如下图所示,网关正确鉴权,提示:无此权限

如果你将登出接口配置成普通用户拥有的权限,如 app:note:publish

SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("app:note:publish"));

重启网关服务,再次测试,如下图所示,鉴权就通过啦:

至此,我们就已经完整的体验了,如何在微服务网关中,通过 SaToken 框架对接口进行统一鉴权,希望小伙伴们学完后能够有所收获~

本小节源码下载

https://t.zsxq.com/Q09ct