最近接到设计一个关于权限系统的需求,因为之前没有做过权限系统相关的功能,第一次接触,所以把整个设计思路记录下来。
而在笔者本次设计的权限系统中,根据产品需求最后选择使用的是 DAC 这中权限系统设计模型。当然权限系统模型不止刚刚上文提到的这两种,还有诸如:ABAC(Attribute-Based Access Control)基于属性的权限控制,PBAC(Policy-Based Access Control)基于策略的权限控制等。接下来将会以 DAC 这一设计模型实现落地到代码中。
通常我们都是基于业务功能或资源操作来进行权限划分的,比方说文章管理这一个业务模块,一般来说可能会有增删改这几个基本操作,那么我们可以将系统中需要被权限管理的功能操作都保存在一张系统权限表里:
| ID | 操作类型 | 操作说明 |
|---|---|---|
| 1 | ARTICLE_ADD | 文章新增 |
| 2 | ARTICLE_DELETE | 文章删除 |
| 3 | ARTICLE_UPDATE | 文章更新 |
| … | … | … |
有了系统权限表以后,接下来肯定还需要一张表去保存用户拥有那些权限,由于没有系统角色这个概率,我选择直接将当前用户的所拥有的系统权限列表作为一个属性存储在用户表:
| 用户ID | 用户姓名 | 权限列表(JSON数组) |
|---|---|---|
| 100 | 小明 | [“ARTICLE_ADD”, “ARTICLE_DELETE”] |
| 101 | 张三 | [“ARTICLE_UPDATE”] |
在上面的示例中,我使用系统权限表的 操作类型 字段作为权限的标识符存储到用户权限列表中,你也可以使用系统权限表的 ID 来存储到用户权限列表中,这取决于你的实现。
有了上面这两个表后,我们基本就可以很清楚的看到每个用户拥有什么权限,而给用户新增权限和移除权限只需要在用户的权限列表上做操作就行了。而到这一步我们只是实现了权限设计系统中的用户的权限管理,接下来要做的就是识别用户,然后做权限的认证。
由于该系统用户认证使用的是 Spring Security,所以在权限认证这一块依然会与 Spring Security 搭配使用,所以在集成权限认证这一块就会很简单,我们只需要在用户认证的时候把该用户的权限列表一起存入到 SecurityContext 这个上下文对象中即可,然后配合 @PreAuthorize 注解使用:
@PreAuthorize("#hasAuthority('ARTICLE_DELETE')")
public void deleteById(int id) {
// ...
}
但是在实现这个权限系统的过程中,在存取用户权限列表的时候却让我犯了难,首先用户的权限列表肯定不能每次都从数据库获取,那样太影响性能了,那么这个用户权限列表应该被缓存起来,那么应该缓存到哪里了?如果缓存到当前应用进程内,由于这是一个分布项目,意味着可能每一个服务实例都会有这样一份缓存数据,而且缓存的一致性也比较难维护,那么放到 Redis 如何了?显然比刚才两种方案会比较好,但是还是免不了一次网络请求,依然不够完美。其实最理想的是将用户权限列表与 Token 放在一起,这样我们就不需要在服务端维护权限列表,这才应该是分布式项目开发打开的正确方式,以此来保证你的服务无状态的。
但是,由于我们使用的 DAC 这种用户直接授予权限的系统设计模式,而非 RBAC 用户授予角色的这种方式,意味着我们的权限列表可能会非常大,虽然我们可以将权限列表保存的 操作类型 替换成 ID 以此来减小权限列表,但是这仍然不够小。
其实对于权限,无非就是有或者没有这两种结果,对应到代码就是 true 或者 false,那么再往下一点细化刚好可以用一个二进制位表示 0-没有权限 1-有权限,而我们要做的就是如何把用户的权限列表与按 bit 位展示,首先我们需要将当前系统中所有的权限建立一个索引 Map 映射。
// key=> 权限唯一标识符,可以是操作类型字符串,也可以是权限ID
// value=> 从0开始自增的索引
Map<String, Integer> authIndexMap = new LinkedHashMap<>();
| Key(权限操作类型) | Value(自增索引) |
|---|---|
| ARTICLE_ADD | 0 |
| ARTICLE_DELETE | 1 |
| ARTICLE_UPDATE | 2 |
还是以上面的文章管理模块为例子,那么这个 Map 最终会是像上面这样的,在这里我们建立了一张权限索引 Map 映射表,表示索引 0 第一位比特位代表是否有文章新增的权限,索引 1 第二位比特位代表是否有文章删除的权限,索引 2 第二位比特位代表是否有文章更新的权限,那么假设用户小明的比特权限列表是这样的:0 1 0,那么通过查权限索引 Map 映射表可以得出,小明的比特权限列表第一位是 0 表示没有 ARTICLE_ADD(新增文章)的权限,第二位是 1 表示拥有 ARTICLE_DELETE(删除文章)的权限,第三位是 0 表示没有 ARTICLE_UPDATE(更新文章)的权限。可以看到我们只需要额外的一张权限索引 Map 映射表,我们可以将用户权限的列表缩减到 3 个比特位来表示,接下来我们就可以把这个缩小后的权限列表放入 Token 当中。
那么如何去操作或生成用户的权限列表比特了?在 java.util 包下有一个 BitSet 工具类,可以很方便的来帮助我们操作比特位,下面是一个非常简单的例子:
// 当前系统的权限列表,实际开发中从数据库获取的
List<String> systemAuthList = Arrays.asList("ARTICLE_ADD", "ARTICLE_DELETE", "ARTICLE_UPDATE");
// 根据系统的权限列表建立权限索引 Map 映射表
Map<String, Integer> authIndexMap = new LinkedHashMap<>();
for (int i = 0, size = systemAuthList.size(); i < size; i++) {
authIndexMap.put(systemAuthList.get(i), i);
}
// 小明拥有的原始用户权限列表,实际开发中从数据库获取
List<String> authList = Collections.singletonList("ARTICLE_DELETE");
// 创建属于小明的 Bit 权限列表,初始所有的比特位0,即为 false
BitSet authBitSet = new BitSet(authList.size());
for (String auth : authList) { // 遍历小明所拥有的权限列表
// 通过查找权限索引映射表,得到当前这个权限的索引位置
Integer index = authIndexMap.get(auth);
// 将小明的 Bit 权限列表对应索引位置设为 true 表示拥有这个权限
authBitSet.set(index,true);
}
// 接下来就是验证了
System.out.println(authBitSet.get(0)); // false
System.out.println(authBitSet.get(1)); // true
System.out.println(authBitSet.get(2));// false
到这里我们已经将用户的 Bit 权限列表建立起来了,那么如何序列化保存了?
// 将当前的比特位图转换为 Long 数组
long[] longs = authBitSet.toLongArray();
// 将当前的比特位图转换为 Byte 数组
byte[] bytes = authBitSet.toByteArray();
System.out.println(Arrays.toString(longs)); // [2]
System.out.println(Arrays.toString(bytes)); // [2]
其实在 BitSet 内部维护了一个 long[] words 数组,比方说我们操作 BitSet 第2位比特位的时候,其实就是操作 words 数组索引0位置的 long 值,那如果操作第100位比特位的时候其实就是操作 words 数组的索引位置1的 long 值,因为 Java 的 Long 长度为 64 位,所以就被定位到第二个 long 值的比特位上面了。通过上面将用户的权限列表转换成 long 数组以后,我们就可以在用户登录成功的时候,一起放入到用户的 Token 当中,接下来就是如何基于比特位操作来完成权限的认证,由于 Spring Security 并不认识我们的权限比特位图,所以我们需要自定义权限认证。
// 默认调用的是 SecurityExpressionRoot 对象的方法进行权限认证的
@PreAuthorize("#hasAuthority('ARTICLE_DELETE')")
public void deleteById(int id) {
// ...
}
@PreAuthorize 注解是可以使用 SPEL 表达式进行权限认证的,这就留给了我们可以自定义权限认证的扩展机会,所以我们可以自己写一个权限认证的类,然后在注解里写表达式调用我们自己的权限认证方法:
@Component("auth")
public class AuthorityControlCenter {
// 根据系统的权限列表建立权限索引 Map 映射表
private Map<String, Integer> authIndexMap;
public boolean hasAuthority(String authority) {
// 从上下文对象中拿到已经认证成功的用户信息
// 其中应该包含我们在 Token 里放入的权限 long 数组
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserInfo userInfo = (UserInfo)authentication.getPrincipal();
long[] auth = userInfo.getAuth();
// 还原用户的比特位图权限列表
BitSet authBitSet = BitSet.valueOf(auth);
// 通过查找当前认证权限的索引位置
Integer index = authIndexMap.get(authority);
// 拿到该权限索引位置来判断用户是否有该权限
return authBitSet.get(index);
}
}
上面是一个很简单的自定义权限认证控制中心,首先需要从 ThreadLocal 拿到认证成功的用户信息,这其中应该包含解析 Token 时的用户权限列表的一个 long 数组,我们通过使用 BitSet#valueOf 方法将一个 long 数组还原成比特位图,然后通过查找权限索引 Map 映射表找到当前参数权限表示的索引位置,最后通过该索引去用户的权限比特位图查找是否有该权限。
// 调用我们自己定义的权限认证方法
// @ 符号跟上 Bean 对象的名字 . 方法的名字即可
@PreAuthorize("@auth.hasAuthority('ARTICLE_DELETE')")
public void deleteById(int id) {
// ...
}
其实如果你的权限标识符不是字符串的话,而直接使用权限的 ID 作为权限唯一标识的话,那么我们甚至都不需要这个权限索引 Map 映射表,但是应该尽量保证你的 ID 是从 0 开始自增长的,以此来保证比特位不会浪费太多。
另外在进行权限认证将 long 数组还原成 BitSet 对象这一步也不是非必须的,参考了 BitSet#get 方法的源码,可以直接对 long 数组进行权限比特定位查找,方法实现如下:
/**
* @param words 权限 long 数组
* @param bitIndex 需要查找的权限索引位置
* @return 比特位值不等于0为true
*/
public static boolean get(long[] words, int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = bitIndex >> 6;
return (wordIndex < words.length) &&
(words[wordIndex] & (1L << bitIndex)) != 0;
}
工具 — Mar 15, 2023