24 KiB
在前面小节中,我们对网关中的 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;
}
}
查询角色
前置工作完成后,添加 StpInterfaceImpl 类 getRoleList() 方法中,获取当前用户对应的角色集合的逻辑代码:
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 框架对接口进行统一鉴权,希望小伙伴们学完后能够有所收获~