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中进行添加!
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
admin_id由登陆的时候获取,也就是userId(主键id),以admin表中id=4,对应admin_role_relation,也就是roleId=5
roleId_permission
接下来查询roleId_permission表,获取当前角色具体的权限
可以查到roleId=5,怎么找不到相关信息?这里是因为我们恰好拿的是系统管理员,而系统管理员它具有所有权限,我们单独来做处理,也避免了每次还要慢慢的查表。
回到第一张表,我们选择adminId=6,对应roleId=1,这里我们也不妨看看它对应的是什么角色:
role
原来是管理商品的,那么它对应的权限、资源也应该是商品相关的,我们看是不是这样呢?
根据roleId_permission表,可以知道对应的permissionId由:[1,2,3,7,8]
permission
依次查看permission表,看看他们对应的是什么:
也就是[商品,商品列表,添加商品,编辑商品,删除商品]
对应的也就是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
还是以roleId=1为例,这里对应的resources:[32,31,24,23,6,5,4,3,2,1]
resource
同样我们可以去resource中具体看是什么:
最右边还有一个字段hidden,0-前端不隐藏,1-前端隐藏
category_id 对应的是resource_category 中的数据,作为外键:
resource_category:
为什么不跟上面的表合并为一张呢,而是单独设计一张表呢?主要还是为了方便后面维护,新增、删除模块都只是关联修改的问题。
我们现在只关心当前操作人是否有权限操作编辑商品接口,我们查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做权限认证、接口路径权限访问就完结了