权限体系设计
概述
OAuth2 协议设计的初衷,并不是为了解决“用户”系统授权问题的,主要是为了解决“应用”(系统和系统之间,组件和系统之间)的授权问题。具体的可以阅读高级文章:【OAuth 2 中的 Scope 与 Role 深度解析】。
为了让 OAuth2 协议的适用性更强,可以支持更多应用场景,特别是目前流行的前后端分离的、微服务系统,将 OAuth2 和其它权限模型进行整合,例如:RBAC。所以,从某些角度讲基于“用户”的授权是开发者们“强加”在 OAuth2 中的。当然,OAuth2 协议也认识到了这个不足,所以才会有 OIDC 协议的产生。
提示
OIDC (OpenID Connect) 协议,就是在 OAuth2 协议之上的扩展协议,OAuth2 协议之上增加了一层“用户”信息的支持。
即使有了 OIDC 的支持,甚至将 OAuth2 协议与其他权限模型进行了融合,由于 OAuth2 协议自身的局限,能否真正的理解以及灵活运用也是一项需要长时间投入的事情。
[一]融合 OAuth2 和 RBAC 模型的权限体系
Dante Cloud 在权限模型的设计中也选择了大家较为熟知和易用的 RBAC 模型。同时为了解决前面提到的问题,在融合 OAuth2 和 RBAC 方面也做了加大的努力。下图是 Dante Cloud 权限体系的 ER 图:
[1]权限体系设计
用户权限采用 RBAC 模型,所以主体设计与标准的 RBAC 基本一致。该部分的 ER 图如下所示:
标准的 RBAC 模型,正常情况包含:用户
、角色
和 权限
三个组成要素。因为微服务系统主要是面向 REST 接口,所以在此基础之上使用 RBAC 模型,那么对应的三要素就为用户
、角色
和 接口
。这里的 接口
就是 RBAC 中的 权限
。
之所以说 Dante Cloud 与标准的 RBAC 基本一致,意味着还是有一定的改造。在 用户
、角色
和 权限
三要素的基础之上又增加了一层 属性
,其中:
用户
:对应数据表sys_user
角色
:对应数据表sys_role
权限
:对应数据表sys_permission
属性
:对应数据表sys_attribute
其中,权限
即真正使用的 RBAC 权限。这个权限就是最终放入到 Token 中,用于接口鉴权的权限。
[2]为什么要这么设计?
在早期的 Dante Cloud 中就是使用 REST 接口作为 RBAC 中的权限,即:为用户
、角色
和 接口
。但是通过长时间的实践,发现这种方式并不适合,主要原因是:
- JWT Token 过长:直接使用 REST 接口作为权限,权限数据是需要放入到 Token 中,因为字符过多会导致生成 JWT Token 过长。权限数据越多,Token 字符串越长。
- 权限配置繁琐:随着业务系统的功能越多,接口也会越多。接口多了非常不利于权限的管理和控制。
- 功能开发混乱:微服务是以
REST 接口
为基础,但是前端却是以功能
为基础。前端一个按钮操作可能就涉及多个接口。
所以,增加了一层权限设计,这样做可以带来以下好处:
- 配置权限更加简洁:只要配置指定权限即可,无需配置过多的接口。
- 实现接口权限的聚合:多个接口对应一个权限。一个权限可以对应一个功能或者其它细粒度的权限设计
- 方便后续扩展:REST 接口只是权限的一种,可能在你的系统中还会涉及其它权限元素。那么这些内容可以统一扩展至权限数据上。
[3]特殊处理
除了 sys_user
、sys_role
、sys_permission
和 sys_attribute
四个 RBAC 核心表之外,在数据表中还有一个特殊的数据表 sys_interface
。sys_interface
表中的数据与 sys_attribute
基本一致,只是多了特殊的配置而已。
主要的逻辑为:
- 各个服务中的 REST 接口,在服务启动时会汇总至
sys_interface
表中 - 全部服务启动完成之后,会将有变化的数据同步至
sys_attribute
表中
之所以这样设计,是为了平衡 JPA 自身的机制局限与性能损耗。
JPA 的保存机制是:如果保存实体没有 ID 则进行
insert
做操作,这是会自动分配 ID;如果保存实体有 ID 则进行update
操作,这里的更新是会更新全部字段,你可以把这个过程的结果效果“想象”成是先delete
后insert
。注意:并不是真的做了这样的操作。当然,JPA 提供了指定更新字段的机制,但是这样会进一步损耗性能。
了解了这个机制我们看现在的设计。
现在的设计是以服务、接口映射等信息组成ID,在服务启动时对当前服务所有的接口进行扫描和拼装,然后汇总到 sys_interface
。初次部署时这些数据是以 insert
方式保存至数据库。服务再次启动时,就会进行 update
操作。按照前面的逻辑,在 update
时所有的数据都会被更新。在这种情况下,如果在 sys_interface
表中增加了额外的字段,不管设置了什么值,update
时就会被更改为初始值。
为了解决这个问题,增加了 sys_attribute
表的设计。sys_attribute
表中的字段与 sys_interface
表中的字段大体一致,业务需求的额外数据均在 sys_attribute
表中涉及。每次服务启动完成,会对比 sys_interface
表中比 sys_attribute
表中多出的新增数据,然后将新增数据同步至 sys_attribute
表中,这样就仅做 insert
操作,不会影响 sys_attribute
表中额外的业务字段。
[4]界面操作
注意
增删改查常规操作就不一一展示了,只展示相关部分内容
[二]前端界面权限
前端界面控制的权限,并没有像传统单体系统一样,将其直接与 RBAC 融合。而是利用 RBAC 中的角色,单独额外提取一套类似于 RBAC 的结构进行处理,该部分的 ER 图如下所示:
这样设计就意味着系统存在两套权限
。一套负责后端接口权限,一套负责前端页面权限。设计思路,参见下面:【[七]设计思路总结】
[三]独有设计的动态接口鉴权
基于 Spring Security
的微服务架构,具体接口的鉴权是在接口所在的服务自身完成。
大多数使用 Spring Security
组件的系统,都会采用注解 @PreAuthorize
的方式,将权限数据写死。如果要修改权限或者存在权限变更,就需要通过修改代码来完成。Dante Cloud 设计了独有的动态权限体系,通过界面操作即可动态修改权限。无需修改代码或者重启服务。具体操作演示如下所示:
警告
即使实现了接口的动态鉴权,权限变更之后仍旧需要退出系统重新登录。因为,权限数据均存储在 Token 之中。设计思路,参见下面:【[七]设计思路总结】
[四]静态配置接口鉴权
有些情况下对于一些不会经常变化的接口或者需要临时进行验证的一些接口,启动服务配置动态鉴权可能略显繁琐。系统也提供了另外的一种鉴权管理方式:静态配置接口鉴权。方便开发调试或者统一管控需求。
Dante Cloud 在几个方面提供静态配置接口鉴权支持:1、网关统一管控;2、服务静态鉴权;3、统一开放配置
[1]网关统一管控
网关,是微服务系统的安全门户,是系统安全的重要保障。对于网关服务后端的所有服务,提供“宏观性”的管理保障。对于整个系统管控以外的接口,通过网关服务就会拦截,不会进入到后续服务中,以保障整体的安全性。
在 Dante Cloud 中,网关服务仅做简单的鉴权处理:
- 包含 Token 的接口访问,允许进入到后端服务进行二次鉴权。
- 网关服务配置的静态权限,即使没有 Token 也允许进入到后端。(例如:系统中会存在一些特殊的开放性接口,这些接口一般不需要用户登录即可使用,这种情况下网关就需要放行)
- 对于既不包含 Token 也不在静态鉴权配置中的接口,网关服务即直接拦截返回。这样也避免了后端无畏的性能损耗。
在 Gateway 的配置文件中即可以直接修改网关的静态权限配置:
herodotus:
gateway:
white-list:
- "/oauth2/token"
- "/oauth2/authorize"
- "/v3/api-docs/**"
- "/openapi*"
- "/open/**"
提示
也许会有朋友想到:能否将网关的鉴权机制改成动态方式,可以通过界面统一管理?
在 Dante Cloud 的设计理念中,网关一定要保持简洁和高安全性,在网关服务增加的 Gateway 自身支持以外的功能越多,安全性就越低,整体架构也越不符合规范。在有些系统的设计中,主打所谓的“网关统一动态鉴权”,个人认为是一种“四不像”的设计,具体解释可以阅读高级文章:【《OAuth 2 中的鉴权和动态接口鉴权》】
[2]服务静态鉴权
服务静态鉴权是一种便捷的配置权限的方式,大多情况下是为了开发和调试方便。例如:正在开发一个接口,又不想进行繁琐的权限配置,那么就可以将其配置成静态权限。当然,这里所说的方便开发和调试,仅仅是一种建议,你完全可以结合自己的使用应用,将静态权限应用于更多场景。
服务静态权限也是通过服务的配置文件进行配置,示例配置如下:
herodotus:
oauth2:
authorization:
matcher:
permit-all:
- /security/social/binding/list
更多的配置,可以参见:【资源服务属性配置】
[3]Ant 风格权限支持
在 Dante Cloud 中,所有静态权限均支持 Ant 风格路径模式。
Ant
风格就是一种路径匹配表达式。主要用来对 uri
的匹配。其实跟正则表达式作用是一样的,只不过正则表达式适用面更加宽泛,Ant
仅仅用于路径匹配。
Ant 风格的路径模式的基本通配符
*
:匹配零个或多个字符。**
:匹配路径中的零个或多个目录,可以跨多级目录。
通配符的用法及示例
/user/*
:匹配/user/name
、/user/game
、/user/123
等路径。/user/**
:匹配/user/name
、/user/game
、/user/123
、/user/files/filename
等路径。
Ant 风格路径模式权限配置
Dante Cloud 中使用 Ant
风格路径模式配置权限示例如下:
herodotus:
oauth2:
authorization:
matcher:
permit-all:
- /security/**
[五]动态权限和静态权限去重混合鉴权
在 Dante Cloud 中,动态权限和静态权限并不是各自独立的,而是互相融合的。
[1]权限去重
如果服务中配置了静态权限,并且该静态权限是 Ant
路径风格的权限。或者动态权限中,REST 接口也是 Ant
路径风格。那么服务在做接口到权限的转换过程中会进行权限重叠分析,将可覆盖的权限进行剔除,而选用覆盖范围最高的权限作为鉴权元数据。
具体逻辑可以参考以下示例:
在服务A中,存在两个接口:一个接口 a
为 GET
- /security/permission/{id}
, 另一个接口 b
为 GET
- /security/permission/list
。
因为在 Ant
风格下,a
可以涵盖 b
,所以经过系统分析之后,会以 a
作为鉴权元数据同时忽略 b
。
[2]权限优先
Dante Cloud 鉴权列表分析的主要逻辑为:
- 首先将动态权限和静态权限中,
Ant
路径风格权限进行汇总 - 然后分析所有的非
Ant
路径风格权限。如果某个权限可以与Ant
路径风格权限匹配,那么会将该权限从权限列表中剔除,不再参加鉴权校验。
所以,根据该逻辑设计 Ant路径风格权限的优先级 > 非Ant路径风格权限的优先级
。
[六]OAuth2 Scope 接口鉴权
在使用 OAuth2 时,经常让人比较疑惑的知识点就是 Scope
。OAuth2 默认的权限就是 Scope
,而不是我们熟悉的接口(REST API)。
在使用 OAuth2 时,我们可以通过设计实现,修改 OAuth2 所使用的权限体系,比如和 RBAC 权限体系结合,让其不再局限于使用 Scope
。但是由于 RBAC 权限体系是面对“用户”的,所以与 OAuth2 结合以后,只能对授权码模式(Authorization Code Grant
)、密码模式(Resource Owner Password Credentials Grant
)以及其它自定义的、涉及需要“用户”参与的授权模式起效。
想要深入了解,可以阅读【OAuth2中Scope与Role】。
对于像客户端凭证模式(Client Credentials Grant
)这种,完全没有“用户”参与的授权模式,权限体系还必须使用 Scope
。这就导致:
- 如果我们开发涉及 REST 接口的、使用客户端凭证模式的应用,使用
Scope
是无法保护接口安全的。 - 在
Spring Authorization Server
的标准实现中,Scope
权限的验证也仅仅是字符串的比较,达不到安全需求。
因此,Dante Cloud 专门对 Spring Authorization Server
进行了扩展,支持将 Scope
权限与接口进行绑定。配置好 Scope
权限之后,就可以直接对请求的接口进行权限校验。相关 ER 图如下图所示:
提示
这是 Dante Cloud 中比较具有特色的功能,目前还少有开源微服务系统支持。
Dante Cloud 对客户端凭证模式进行了扩展,实现了通过分配 Scope
权限就可以对 REST 接口进行访问控制的能力。验证的方式如下:
- 以接口
/security/user
为例,先请求一下这个接口,可以看到系统返回没有访问权限的提示,如下图所示
- 我们为这个接口配置一个客户端凭证授权模式,同时指定其授权范围
Scope
为read-user-by-page
- 再次请求接口
/security/user
,系统返回了接口响应数据
[七]设计思路总结
熟悉传统单体系统设计和开发的朋友,看到前面对本系统权限体系设计,可能会感觉很疑惑,甚至觉得与以往的认知格格不入。
还是本人常说的,单体架构和微服务架构不是简简单单的将一个系统拆分为多个服务,看似两者差不多实则在开发方法和设计思路方面有较大差别。使用微服务之前还是建议学习相关的知识,先改变以往开发的认知和习惯。否则只是简单的将单体应用中内容往微服务中生搬硬套,只会适得其反。
列举一下单体架构和微服务架构的主要差别,方便大家更好的理解以上的内容。
注意
本文所说的单体架构特指的是传统的单体应用,即传统的使用 JQuery、Bootstrap作为前端核心组件,前端页面和后端打包在一起的应用。
本文所说的微服务架构,除了标注意义上的微服务系统以外,还包含前后端分离的单体应用。即:前端使用例如 Vue 之类的组件,前端是单独工程需要单独部署的架构。这两中架构复杂度不同,但是设计和开放逻辑相同。
[1]核心元素和存放位置不同
单体架构的核心元素
单体架构的核心元素是 Session
。Session
由后端服务生成,并且存放于服务端内存中,可以直接通过代码读取和设置。
Session
本质是为了解决 Web 应用的无状态性,所以传统单体项目用了 Session
可以说就是“有状态”的。所以传统单体系统所有的权限或者用户标识等信息均存放于后端 Session,变更权限、读取权限也相对方便,也可以“实时”改变 Session 中数据的状态。
微服务架构架构的核心元素
微服务架构(前后端分离)的核心元素是 Token
。Token
由后端签发之后,是存放于各种类型的“前端”中。Token
是无状态的,所以用户相关的权限是要存储于 Token
中。
虽然,最终的鉴权还是在服务端,但是无法像 Session
一样“实时”修改 Token
中的内容。即使修改了后端存储的 Token,已经签发并且存放于前端的 Token 是无法修改的,只能通过重新生成 Token 的方式,比如说重新登录才能更新 Token 中的内容。
[2]权限面向的元素不同
单体架构的权限元素
传统单体架构核心的权限元素,通常是页面地址(如果使用的是 Spring 组件,那么这里所说的页面就是 @Controller
对应的代码)。比如说功能菜单对应的都是各个页面,而权限体系也是根据这些内容进行构建。当然,也可能会以“按钮”之类作为权限元素,但是终归是页面上的元素。
不管是页面上的元素作为权限还是实际的页面作为权限,都可以存储在后端的 Session
中随取随用。而且修改了权限数据后,页面也可以同步变化显示内容。前端是不容一获取到 Session 中存储的信息的。
微服务架构的权限元素
微服务架构核心的权限元素,对于后端来说就是 REST 接口(如果使用的是 Spring 组件,那么这里所说的页面就是 @RestController
对应的代码)。对于前端来说又是另外的权限元素。
虽然,可以将前端的权限元素存储在也存储在 Token 中,就像单体架构存储在 Session 一样。但始终会存在问题:
- 前后端分离的应用,前端一个功能或者一个操作,可能对应后端多个接口。如果将两者融合在一起,权限逻辑会非常混乱
- 如果将前端权限数据也存储在 Token 中,那么会导致 Token 越来越长,增大传输压力。而且要是自包含的 JWT Token,权限数据会和容易暴露。
[3]面对的前端种类不同
单体架构的前端种类
传统单体架构的前端,不管使用的是 JQuery 等组件还是原始的 JSP 等技术,最终面对的还是在同一个浏览器中运行的 HTML。
如果你曾经做过让单体应用支持移动端,不仅需要再重新创建一套 Token 体系,而且会发现用法与原来的权限体系格格不入。
微服务架构的前端种类
微服务架构所面对的前端,已经不仅仅只包含传统的 Web,还会包括原生移动端、小程序甚至桌面程序,前端的元素会是多种多样的。如果这么多前端的权限控制,都与后端接口融合在一起,那么将会是多么混乱的意见事情。
[4]应对变更的态度不同
单体架构应对变更
单体架构如果需求变化或者代码修改后,需要整体代码进行发布。一些企业发布管理流程和周期可能会非常长,也因此导致单体架构的设计偏重灵活性设计,就是在尽量不停机不发布的情况下,通过后端配置就可以产生更多变化以适应更多客户的需求。比如说:常说的数据权限就是由此而来。
微服务架构应对变更
微服务应对需求的变更的逻辑并不是“以不变应万变”,而是配合容器、DevOps、云技术可以实现快速的、大规模的发布和扩展。所以遇到变更,会通过修改代码,然后通过自动化手段,快速的发布部署。所以才会产生蓝绿部署、滚动部署、灰度发布、金丝雀发布等各种不同的发布策略。
一个很多朋友都会问到的问题:微服务架构是否支持数据权限。我的回答是:一方面不需要,如果可以实现快速发布,个人认为给接口增加个参数要远远优于配置一大堆所谓的数据权限;另一方面,实际的微服务会有很多个服务,可能一个服务会有多个实例,这种情况下做个数据权限去应对变化,岂不是自讨苦吃。
提示
这些内容完全是本人多年的积累和理解,不奢求各位能够完全理解和认同,也不一定就是正确的。如果您有不同的见解或建议,欢迎大家新建 ISSUE 讨论。