5.权限 - 图1

5.权限

本章讨论以下 5 个话题:

  • 什么是权限?
  • Shiro 中如何定义权限?
  • WildcardPermission 源码解析
  • 权限是如何与资源进行关联的?
  • 权限是如何与主体进行关联的?

5.1 什么是权限?

在安全领域,权限指的是主体对资源进行访问或操作的权利。例如,用户可能被授权访问某个网页、编辑文档,或执行系统命令等操作。这些场景可以抽象为三个关键要素:主体、操作、资源。

显然,三要素缺一不可:如果没有主体,权限毫无意义;如果没有资源,就没有必要定义权限;如果不存在操作,则说明主体与资源之间不会发生关系,那么定义权限也是多此一举。

也就是说,只有主体、资源、主体对资源存在某种操作,这 3 个要素同时存在的时候,我们才有必要定义权限。

为了方便理解 3 个要素之间的关系,我们从数据库表的角度来观察,在 ER 图(Entity-Relationship Diagram,实体关系图)上,主体、权限、资源之间的关联关系如下:

5.权限 - 图2

备注:在主体和权限之间实际上还有角色(Role)表,为了便于理解,这里先忽略角色,我们会在独立的章节中分析角色的设计和实现。

5.2 Shiro 中如何定义权限?

5.2.1 基本用法

在 Shiro 中,是通过一组字符串来定义权限的。每一个权限字符串通常由一组单词构成,这些单词默认通过冒号分隔,形成权限表达式,例如:

  • user:create:表示创建用户的权限
  • user:update:表示更新用户的权限
  • user:delete:表示删除用户的权限

当主体持有 user:createuser:update ,但是却试图执行 user:delete 相关的操作时, Shiro 将会拒绝操作。开发者可以使用 Shiro 提供的权限注解来控制某个方法的权限,例如使用 @RequiresPermissions 注解:

  1. @RequiresPermissions("user:delete")
  2. public void doSomething() {
  3. //...
  4. }

以上伪代码中的 doSomething() 方法被 @RequiresPermissions 注解进行了装饰,而且要求主体持有 “user:delete” ,Shiro 在执行这个方法之前会首先检查权限,如果权限不够,则不会执行方法。

那么, Shiro 是如何进行检查的,具体的运行机制和代码实现细节是什么?请读者翻阅第 3 章的 3.4.2 节,和第 11 章中的内容,这里我们聚焦权限表达式本身。

Shiro 没有限制权限表达式中需要含有几个单词,也没有限制这些单词的顺序,所以开发者把权限定义成 create:user 也是可以的。但是,权限字符串通常遵守资源:操作的格式,这种格式有以下优点:

  • 逻辑清晰:权限控制的核心是让某个主体(如用户)执行某种操作(如查看、编辑)在特定的资源(如文档、用户信息)上。将权限字符串分为 资源操作 能够直接表达这两个关键元素,逻辑上更加清晰。
  • 可读性强资源:操作 格式结构化地表达了权限的组成部分,开发者一眼就能看出权限所涉及的资源是什么,以及允许的操作是什么。例如,user:create 明确表明这条权限是允许创建用户。
  • 可扩展性强:这种格式允许轻松扩展权限控制系统。如果有新的资源或操作出现,只需添加新的权限字符串即可。例如,如果需要添加用户删除权限,可以很自然地定义 user:delete
  • 层次分明:资源通常比操作范围更广泛。通过先定义资源,再规定具体操作,权限管理系统可以逐级处理不同类型的资源和操作,保持权限分配的层次性。
  • 一致性资源:操作 格式已成为一种约定俗成的标准,很多权限管理系统、框架都采用类似的模式。这种一致性帮助开发者理解和维护权限配置,更方便在不同系统间迁移和集成。

尽管 Shiro 没有强制使用资源:操作这种格式,但由于其清晰和实用性,已经成为了一种最佳实践。

5.2.2 带有通配符的权限表达式-WildcardPermission

在前面的内容中,我们编写了以下权限表达式:

  • user:create:表示创建用户的权限
  • user:update:表示更新用户的权限
  • user:delete:表示删除用户的权限

我们可以使用 @RequiresPermissions 注解来使用这些权限字符串:

  1. @RequiresPermissions("user:create")
  2. @RequiresPermissions("user:update")
  3. @RequiresPermissions("user:delete")
  4. public void doSomething() {
  5. //...
  6. }

代码没有任何问题,而且看起来很清晰,但是太啰嗦了,所以 Shiro 支持用逗号分隔的模式,我们可以写成这样:

  1. @RequiresPermissions("user:create,update,delete")
  2. public void doSomething() {
  3. //...
  4. }

用逗号分隔之后看起来简洁多了,对吧?但是,在实际的业务系统中,我们还会遇到更加麻烦的需求,比如:允许对用户执行所有操作

对于这样的需求,如果我们把资源上的每一种操作都详细列举出来,那么代码就会非常啰嗦,而且可扩展性也很差(有经验的开发者都知道,如果把某个概念限定得太具体,以后就不好增加或者修改了)。所以,Shiro 引入了通配符 * 来进一步简化权限表达式,这种方式称为通配符权限(WildcardPermission)

因此,对于允许对用户执行所有操作这样的需求,我们可以将权限表达式写成 user:*。显而易见,一旦主体拥有了 user:* 这一权限字符串,它就“覆盖”了所有与用户相关的具体权限,意味着主体可以对用户执行任何操作。而且,通配符权限还可以带来一个好处,如果以后新增了或者删除了某个操作,不必修改权限表达式。

例如,如果我们同时给某个主体赋予了以下权限:

  • user:create:表示创建用户的权限
  • user:update:表示更新用户的权限
  • user:delete:表示删除用户的权限
  • user:*:允许对用户执行所有操作

那么,最终起作用的就是 user:*,因为它的含义更广,会覆盖其它所有权限表达式。引入通配符之后,权限管理变得更加简洁和高效,而且人类也很容易理解,通配符权限并非 Shiro 独创,在 Shiro 出现之前,类似的机制早已存在。但是 Shiro 用更加清晰的方式在 Java 语言中进行了实现,它是 Shiro 框架最精华的部分。

但是,通配符同时也为权限定义带来了一些挑战。比方说,下面这个权限表达式的含义是什么呢:

*:delete

凭借人类的直觉,我们可以猜测到它的含义是:可以对任何资源执行 delete 操作。从纯技术的角度看,这样的权限定义没有任何问题,但是在实际的业务系统中,这样的权限定义方式很少出现,请问什么样的系统可以让使用者对任何资源都可以执行删除操作呢?而且通配符会覆盖所有其它类似的权限定义,这样定义权限太危险了!所以,很多在逻辑上成立的东西,在现实世界中可能并不成立。

5.2.3 权限的粒度

Shiro 中的权限表达式一般支持以下几种级别的粒度:

  1. 资源级别(Resource Level)
  2. 实例级别(Instance Level)
  3. 属性级别(Attribute Level)

在资源级别上定义权限是最广泛的场景,在这个级别上,权限规定了对某种资源的操作。例如,可以对 user 表进行 CRUD 操作:

  • user:create:表示创建用户的权限
  • user:update:表示更新用户的权限
  • user:delete:表示删除用户的权限

user:delete 这样的权限表达式表示可以对整个 user 表进行 delete 操作,但是,如果要求只能对 ID 为 66666 的那一行数据进行删除,应该如何定义呢?

user:delete:66666

这就是实例级别的权限定义方式,不仅仅指定了资源,还具体指定到了具体的实例。当然,这个权限表达式能约束的范围非常小,它只对 user 表中 ID 为 66666 的这行记录有效,对表中的其它记录都不起作用。

属性级别的权限更加细化,它指定了资源实例或属性的具体操作。例如:可以修改 ID 为 66666 的用户的 userName 属性。这种权限粒度提供了对资源更精细的控制。

user:update:66666:userName

很明显,权限表达式的级别越深,权限的粒度越细;粒度越细,此表达式能约束的范围越小。

从纯技术的角度,我们可以用冒号来分隔无限多字符串,形成非常深的层级结构。但是,在现实的业务开发中,没有人会定义超级长的权限字符串,假设我们定义了一个含有 99 个冒号的权限表达式,这个表达式能够表达什么样的含义呢?

所以,从最佳实践的角度看,Shiro 中的权限表达式一般定义 3 到 4 层就足够应对绝大多数业务场景了。

5.2.4 一些需要注意的写法

现在,我们有了通配符,有了层级结构,这两项特性混合使用的时候,就产生了一些很有意思的写法。比如,以下两种写法:

  1. user:delete
  2. user:delete:*

仔细思考之后,我们就会发现,这两种写法的含义是相同的。

再来看以下两种写法:

  1. user:*
  2. user:*:*

怎么样?含义依然是一样的,实际上,你可以在末尾补充更多 * 号,含义依然是一样的。所以,通配符出现在权限表达式的末尾时,不会起任何作用,可以省略掉。

但是,当通配符出现在其它位置时,就不能省略了,因为省略掉之后权限表达式的含义会发生变化。比以下权限表达式:

  1. user:*:66666

如果我们把中间的通配符省略掉,那就无法理解了。

5.3 WildcardPermission 源码解析

5.3.1 用面向对象的方式调用权限字符串

通过前面的内容,我们已经学会了如何定义权限字符串,在我们的代码中,应该如何使用这些权限字符串呢? Shiro 封装了大量的 isPermitted 方法,让我们可以非常方便地进行调用。

  1. // 获取当前主体
  2. Subject currentSubject = SecurityUtils.getSubject();
  3. // 检查主体是否拥有某个权限
  4. if (currentSubject.isPermitted("user:edit")) {
  5. System.out.println("当前主体拥有编辑用户的权限!");
  6. } else {
  7. System.out.println("当前主体没有编辑用户的权限。");
  8. }
  9. // 检查多个权限
  10. boolean[] permitted = currentSubject.isPermitted("user:delete", "user:view");
  11. System.out.println("删除权限: " + (permitted[0] ? "拥有" : "没有"));
  12. System.out.println("查看权限: " + (permitted[1] ? "拥有" : "没有"));

以上代码中,我们直接使用了权限字符串,但是 Java 是一门面向对象的语言,我们还可以使用强类型的方法进行调用,示例代码如下:

  1. // 获取当前主体
  2. Subject currentSubject = SecurityUtils.getSubject();
  3. // 创建 Permission 实例,注意这里可以显式地 new WildcardPermission
  4. Permission permission = new WildcardPermission("user:edit");
  5. // 检查主体是否拥有该权限
  6. if (currentSubject.isPermitted(permission)) {
  7. System.out.println("当前主体拥有编辑用户的权限!");
  8. } else {
  9. System.out.println("当前主体没有编辑用户的权限。");
  10. }
  11. // 创建多个权限实例进行检查
  12. Permission deletePermission = new WildcardPermission("user:delete");
  13. Permission viewPermission = new WildcardPermission("user:view");
  14. // 使用 isPermittedAll 来检查主体是否拥有所有权限
  15. boolean allPermitted = currentSubject.isPermittedAll(deletePermission, viewPermission);
  16. if (allPermitted) {
  17. System.out.println("当前主体拥有删除和查看用户的权限!");
  18. } else {
  19. System.out.println("当前主体不同时拥有删除和查看用户的权限。");
  20. }

5.3.2 解析 Permission 相关的核心代码

接下来,我们来解析 Permission 相关的源代码,Permission 相关的继承结构如下图所示:

5.权限 - 图3

在以上继承结构中,各个类型的功能和主要方法描述如下(读者浏览即可,无需记忆):

类名 功能描述 主要方法/字段 描述
Permission 权限接口,定义了基本权限的行为。 implies(Permission) - boolean 检查当前权限是否隐含指定权限。返回 true 表示当前权限涵盖了指定权限。
WildcardPermission 一个实现 Permission 接口的类,使用通配符语法表示权限,支持灵活的权限表达。 WildcardPermission(String, boolean) 构造方法,用于创建带有通配符权限的实例。第二个参数表示是否需要严格匹配。
WildcardPermission(String) 构造方法,创建一个简单的通配符权限实例。
implies(Permission) - boolean 判断当前权限是否隐含指定的权限,使用通配符进行匹配。
parts - List\> 权限表达式的组成部分,通常用于存储权限的具体结构(如资源、动作等)。
setParts(String) - void 设置权限的组成部分,解析权限字符串并存储为 parts 列表。
AllPermission 一个特殊权限类,表示拥有所有权限,实现 Permission 接口。 AllPermission() 构造方法,创建一个拥有所有权限的实例。
implies(Permission) - boolean 始终返回 true,因为 AllPermission 拥有所有权限。
DomainPermission 继承自 WildcardPermission,用于定义领域相关的权限。可以设置动作、目标和领域来表示复杂的权限结构。 DomainPermission(String, String) 构造方法,使用域名和操作字符串创建领域权限。
setParts(String, Set<String>, Set<String>) - void 设置领域、目标和动作的权限组成部分。
encodeParts(String, String, String) - void 编码领域、目标和操作部分为权限字符串形式。
getDomain(Class<DomainPermission>) - String 返回权限所属的领域。
domain - String 表示权限适用的领域,例如某个资源的类别或模块。
targets - Set\ 目标,表示权限可以应用到的具体资源或对象。
actions - Set\ 动作,表示可以对目标进行的具体操作,如“查看”、“编辑”等。

其中, WildcardPermission(通配符权限) 是一个非常重要的实现类,它实现了 Permission 接口规定的核心功能。我们先来看 WildcardPermission 的构造方法:

  1. protected WildcardPermission() {
  2. }
  3. public WildcardPermission(String wildcardString) {
  4. this(wildcardString, DEFAULT_CASE_SENSITIVE);
  5. }
  6. public WildcardPermission(String wildcardString, boolean caseSensitive) {
  7. setParts(wildcardString, caseSensitive);
  8. }
  9. protected void setParts(String wildcardString) {
  10. setParts(wildcardString, DEFAULT_CASE_SENSITIVE);
  11. }

可以看到,Shiro 重载了 4 个 WildcardPermission 构造方法,而且在构造方法中,会立即调用 setParts 这个工具方法,把权限字符串解析成单词列表, setParts 方法的实现代码如下:

  1. protected void setParts(String wildcardString, boolean caseSensitive) {
  2. wildcardString = StringUtils.clean(wildcardString);
  3. if (wildcardString == null || wildcardString.isEmpty()) {
  4. throw new IllegalArgumentException("Wildcard string cannot be null or empty. Make sure permission strings are properly formatted.");
  5. }
  6. if (!caseSensitive) {
  7. wildcardString = wildcardString.toLowerCase();
  8. }
  9. //这里很关键,用冒号分隔的权限表达式会被 split 成单词列表
  10. List<String> parts = CollectionUtils.asList(wildcardString.split(PART_DIVIDER_TOKEN));
  11. this.parts = new ArrayList<Set<String>>();
  12. for (String part : parts) {
  13. Set<String> subparts = CollectionUtils.asSet(part.split(SUBPART_DIVIDER_TOKEN));
  14. if (subparts.isEmpty()) {
  15. throw new IllegalArgumentException("Wildcard string cannot contain parts with only dividers. Make sure permission strings are properly formatted.");
  16. }
  17. this.parts.add(subparts);
  18. }
  19. if (this.parts.isEmpty()) {
  20. throw new IllegalArgumentException("Wildcard string cannot contain only dividers. Make sure permission strings are properly formatted.");
  21. }
  22. }

在以上代码中,我们可以看到:权限字符串中的每一个部分都不可以为空格,也不可以为 null 。

我们再来看 implies 方法,这是最最核心的方法,它负责比较两个通配符权限是否能够互相匹配:

  1. public boolean implies(Permission p) {
  2. if (!(p instanceof WildcardPermission)) {
  3. return false;
  4. }
  5. WildcardPermission wp = (WildcardPermission) p;
  6. List<Set<String>> otherParts = wp.getParts();
  7. //注意这里:遍历两个权限表达式中的单词列表,逐个测试这些单词之间的关系
  8. int i = 0;
  9. for (Set<String> otherPart : otherParts) {
  10. if (getParts().size() - 1 < i) {
  11. return true;
  12. } else {
  13. Set<String> part = getParts().get(i);
  14. if (!part.contains(WILDCARD_TOKEN) && !part.containsAll(otherPart)) {
  15. return false;
  16. }
  17. i++;
  18. }
  19. }
  20. //在完成了以上遍历和比较之后,如果发现当前权限字符串中没有通配符,直接返回 false
  21. for (; i < getParts().size(); i++) {
  22. Set<String> part = getParts().get(i);
  23. if (!part.contains(WILDCARD_TOKEN)) {
  24. return false;
  25. }
  26. }
  27. return true;
  28. }

以上代码中的核心逻辑解析如下:

逻辑块 解释
类型检查 if (!(p instanceof WildcardPermission)) { return false; }:检查传入的 Permission 是否是 WildcardPermission 类型,不是则返回 false
提取比较对象的单词列表 List<Set<String>> otherParts = wp.getParts();:从传入的权限对象中获取其权限的单词列表。
遍历两个权限表达式的单词列表 for (Set<String> otherPart : otherParts):遍历传入的 Permission 对象的单词列表,与当前权限字符串逐个进行比较。
自动蕴含逻辑 if (getParts().size() - 1 < i) { return true; }:在遍历的过程中,如果发现当前权限的单词列表数量少于传入权限的单词列表数量,则视为当前权限蕴含了传入的权限。例如,当前权限字符串为 user:update,而被比较的权限字符串为 user:update:66666,当 for 循环执行到第 3 趟时,发现当前权限字符串已经没有后续单词了,很明显当前权限表达式已经蕴含了被比较的权限。
逐个比较每个单词 if (!part.contains(WILDCARD_TOKEN) && !part.containsAll(otherPart)) { return false; }:若当前单词不含通配符,且字符串不存在包含关系,返回 false
未比较单词列表处理 for (; i < getParts().size(); i++) { if (!part.contains(WILDCARD_TOKEN)) { return false; } }:如果当前权限单词列表多于传入权限单词列表,且多出来的单词列表不含通配符,则返回 false
返回结果 如果遍历完成之后,发现所有单词都完全相同,返回 true

通过阅读 implies 的源代码我们可以看到,在对两个字符串列表进行比较的过程中,Shiro 采用的是充分条件(Implication)校验方式,而不是完全相等(Equality)校验。

5.3.2 性能问题

通过前面的内容,我们已经知道,可以使用 Shiro 内置的权限注解来限制某个方法的权限,例如使用 @RequiresPermissions :

  1. @RequiresPermissions("user:delete")
  2. public void doSomething() {
  3. //...
  4. }

我们每次调用 doSomething() 方法的时候, Shiro 都会进行拦截,首先执行权限比较逻辑,也就是执行上面的 implies 方法。在大型的业务系统中,我们会大量使用 @RequiresPermissions 注解来进行权限控制,这就意味着 implies 方法的调用次数会非常多。假设在我们正在开发一个大型的业务系统,其中有 100 个方法使用了 @RequiresPermissions 注解,这些业务方法平均每秒会被调用 100 次,那么 implies 方法每秒会被调用 100*100=10000 次。有经验的开发者都知道,一旦某个方法达到了这样的调用频率,非常容易成为性能瓶颈。那么, Shiro 在实现 WildcardPermission 这个类的时候,是如何保证 implies 方法的性能的呢?

通过仔细阅读 implies 方法的源代码,我们可以发现 Shiro 在这里花费了不少心思:在比较两个字符串列表的过程中,大量采用短路运算的方式,一旦某个条件被满足,会立即中断 for 循环并返回结果,而不再进行后续的运算。

除此之外, Shiro 还内置了对缓存的支持,可以将权限比较的结果放到缓存中。在特定时间段内,系统将使用缓中存的结果而不是每次都调用 implies 进行权限比较运算。关于缓存机制,我们在独立的章节中进行分析。

5.4 权限如何与资源进行关联?

我们已经学会了如何定义权限表达式,而且也理解了权限表达式之间是如何进行比较的,那么,在实际开发的过程中,开发者应该如何把这些权限表达式与具体要执行的方法进行关联呢?这个任务是由 Shiro 中的权限注解来完成的:

注解 功能描述
@RequiresPermissions 要求当前用户具备指定的权限才能访问方法或类。如果用户缺少指定权限,将抛出 AuthorizationException
@RequiresRoles 要求当前用户具备指定的角色才能访问方法或类。如果用户没有该角色,将抛出 AuthorizationException
@RequiresUser 要求当前用户是已知用户,换言之,用户必须已经登录过(包括“记住我”用户)才能访问资源。
@RequiresAuthentication 要求当前用户已通过身份验证(必须登录且未使用“记住我”功能),否则抛出 AuthenticationException
@RequiresGuest 要求当前用户为访客,即用户未登录或者是匿名用户,已登录的用户将无法访问标注该注解的资源。

关于这些注解的用法和运行机制,在前面的内容中已经详细介绍过,示例如下:

  1. @RequiresPermissions("user:delete")
  2. public void doSomething() {
  3. //...
  4. }

由于权限表达式只是字符串而已,从更加良好的编程习惯角度出发,我们不应该把这些字符串散落得到处都是,更规范的使用方式是定义成常量,然后进行引用,示例如下:

  1. //定义成常量类,避免到处使用 magic string ,在大型团队中,容易导致字符串不一致的情况
  2. public class PermissionsConstants {
  3. public static final String USER_CREATE = "user:create";
  4. public static final String USER_UPDATE = "user:update";
  5. public static final String USER_DELETE = "user:delete";
  6. //...
  7. }
  8. @RequiresPermissions(PermissionsConstants.USER_DELETE)
  9. public void doSomething() {
  10. //...
  11. }

5.5 权限如何与主体进行关联?

在 Shiro 中,我们可以直接给主体赋予权限,也可以通过角色机制来批量赋予权限。具体来说,由Realm 组件负责将用户与角色、权限关联起来。例如,在 doGetAuthorizationInfo 方法中,Shiro 会从数据源中获取用户的角色和权限,并返回相应的 AuthorizationInfo 对象。

  1. @Override
  2. protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
  3. String username = (String) principals.getPrimaryPrincipal();
  4. SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
  5. // 从数据库中查询角色和权限
  6. Set<String> roles = getRolesByUsername(username);
  7. Set<String> permissions = getPermissionsByUsername(username);
  8. authorizationInfo.setRoles(roles);
  9. authorizationInfo.setStringPermissions(permissions);
  10. return authorizationInfo;
  11. }

在这个示例中,getRolesByUsernamegetPermissionsByUsername 方法负责从数据库中获取角色和权限信息,然后将它们设置到 AuthorizationInfo 类型的对象中, AuthorizationInfo 接口的定义如下:

  1. public interface AuthorizationInfo extends Serializable {
  2. Collection<String> getRoles();
  3. Collection<String> getStringPermissions();
  4. Collection<Permission> getObjectPermissions();
  5. }

通过以上源代码我们可以看到, AuthorizationInfo中存储了字符串形式的权限表达式,也存储了面向对象形式的权限实例。

5.6 本章小结

Shiro 提供了一种灵活且高效的通配符权限定义模式,不仅支持细粒度的权限控制,还能处理大规模用户和权限数据的场景。通过短路运算和缓存优化,Shiro 的权限比较逻辑具有很好的性能表现,这一点对于大型业务系统很重要。

资源链接

版权声明

本书基于 CC BY-NC-ND 4.0 许可协议发布,自由转载-非商用-非衍生-保持署名。

版权归大漠穷秋所有 © 2024 ,侵权必究。