这里所描述的资源管理是指API管理,或者接口管理。最初的目是实现一个基于角色权限的细粒度访问控制,同时具备自上而下与自下而上的两种配置与管理方式。

在实际的权限管理中,受特别是后端的权限管理有非常大的不透明性。除了开发者本身,在接口这个粒度上几乎没有人能完全知道系统中,甚至功能点中到底有哪些接口。所以更实际的方式是采用白名单管理,不做配置就没有权限。这种基于白名单或者黑名单的管理方式我称作为自上而下的权限管理。这种方式的缺点就是基于人工的有限配置带来的规模限制与精确控制的限制。

TL;DR点击列表跳转

  1. 资源与权限
  2. 认证管理
  3. 菜单管理
  4. 资源管理
  5. 授权管理
  6. 最后

1、资源与权限

资源权限的分类

对于当前流行的前后端分离系统来说实际上权限管理可以粗略的分别为,前端权限管理与后端权限管理。

  1. 前端权限管理:前端的权限管理可以理解为UI的权限控制,即能看到什么与能操作什么,也就是菜单和按钮。
  2. 后端权限管理:后端权限从不同维度有着不同的划分,但是仅从对外使用上可以简单的认为是接口访问权限管理。

如果按照业界比较成熟的方案,如 Spring securityOauth2 来说了,此类权限管理实际上分为认证管理和资源管理。同时比较成熟的将接口与角色进行静态绑定虽然说对业务限制太大,但也是一种最为直接有效的将认证和资源一同管理的方案。

更加灵活且有操作性的方案是将前端和后端权限进行合并处理。比如菜单与按钮对应的UI侧的可交互性,那么将菜单配置直接绑定到后端接口,就可以在有限的配置内统一管理前后端权限。也就是菜单的授权同样是接口的授权。随后将菜单访问权限授权给角色,角色再授权给用户,形成了一套在大多数情况下可用的便捷的权限控制方案。

存在的问题和局限性

这种混合模式其实配置入口只有菜单管理,所以对于一些在UI上无法展示以及一些公共接口则完全无法配置。这种时候可能需要单独在网关或者认证服务单独开放接口访问,造成很多无法控制的隐患问题。

其次这种UI与接口绑定的方式对于系统的维护和使用非常不友好。因为接口API之类涉及到开发的东西,除了开发人员或者拿到开发文档否根本就不知道接口的准确描述。造成非常高的日常维护和使用成本。

同时与上一条原因相结合,在系统最终运行起来我们几乎是无法得知整个系统的所有接口。但是又打开了绕过授权功能,那么很可能会造成意外授权的问题,存在很大的安全隐患。

我的设计方案

针对上述存在的各类问题,包括实际使用和一些隐患问题,我设计了一套相对来说比较全面同时可行的方案。

首先核心权限使用基于角色的认证控制(RBAC),也就是权限认证和资源管理全部与角色挂钩。更灵活的授权方式在系统稳定后根据使用的便利性进行调整。

用户 -> 角色 ->(菜单)/(资源)

那么至少包括三个基本功能和一个必要功能,

  1. 认证管理:角色,用户等
  2. 菜单管理:前端导航
  3. 资源管理:资源管理在这里可以简单的理解为接口管理
  4. 授权管理:授权管理将资源与认证信息相结合

接下来进行详细说明

2、认证管理 (AuthenticationManage/AUM)

2.1 登录认证

认证管理主要负责登录用户的身份验证,状态控制。

首先是不同维度的认证配置,如是否禁用,是否锁定,是否有登录来源限制。

随后是密码级别认证,角色认证等等。随后将必要的信息编码如令牌(Token)并返回给用户。这对于无论是什么样的系统都属于必须的功能,更多的是关于密码管理,用户管理等,在这里就不过多说明

2.2 访问认证

用户在获得令牌后持有令牌访问后端资源。

后端在拿到令牌后解密获得登录认证阶段编码后的认证信息,这里的认证信息实际上是登录那个时间点的状态快照。
每次解码出的认证信息实际上要再次核对当前时间点的有效性,比如用户信息是否变更,角色与权限是否变更,授权资源是否变更等,并根据实际的业务需要决定是否要无效化当前用户所持有的令牌。

在非长连接会话的场景中,其实大部分场景都没有必要无效化令牌(也就是踢人)。在目前流行且成熟的前后端分离无状态访问的模式下,无效化令牌更多的是应对前端权限的变更,以便让前端用户重新登录刷新UI层的展示与权限控制,避免出现在用户操作中提示无权访问的不友好交互。

认证场景中其实还有一项非常重要的交互与提示文本问题,前后端权限控制越是精细和完善,就越是需要友好且逻辑自洽的交互与提示文本。

3、菜单管理 (MenuManage/MM)

菜单管理本质上是前端的资源与访问权限管理,这里实际上又分为资源展示和资源控制,分别对应菜单和按钮。

3.1 前端资源展示

前端的资源展示是菜单管理最直接的功能,通过树状菜单结构控制UI层可以进入那些页面,不仅可以达到限制资源访问,同时做到前端自己的交互逻辑。

对于WEB应用来说,前端菜单管理可能体现在顶部或者一侧的多级导航。但是对于移动端,除非是那种工具Dashboard类带入口的应用,更多时候菜单是分散在页面各处可视与不可视的路由与组件跳转控制。

如上图所示菜单采用基础表与元数据(meta)分别存储,本质是基础表存储菜单最基础结构,元数据表是将菜单扩展信息从横向表转换为纵向表,进而达到自定义扩展的目的。

菜单结构使用路径枚举(Path Enumeration 或者叫文件路径)的方式存储,目的使得在菜单这种有限层级场景下实现快速的搜索和变更,比如子树快(TreeChildren)速查找。以及快速批量删除。路径枚举可以非常有效的将路径格式转化为 child-parent 模式便于UI层使用。

路径变更/Change ParentNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void preUpdate(@NonNull MenuInfoPo po, EditRequest editRequest, PropertyMap propertyMap) {
MenuInfoEditReq menuInfoEditReq = (MenuInfoEditReq) editRequest;
Long parentId = menuInfoEditReq.getParentId();
if (parentId == null){
po.setModelPath("/" + po.getId());
}else if(!po.getModelPath().endsWith(parentId + "/" + po.getId())){
// 父节点变更
MenuInfoPo parentMenuPo = selectById(parentId);
if (parentMenuPo == null){
throw new ValidateException(SysStatusCode.ERROR_DATA_NOT_VALID, "parent node [" + parentId + "] not exist");
}
po.setModelPath(parentMenuPo.getModelPath() + "/" + po.getId());
}
}

查询子树/Select All Children

1
2
3
4
5
6
SET @model_path = (SELECT model_path FROM sso_menu WHERE id = #{id});
SELECT *

FROM sso_menu
WHERE
model_path like CONCAT(@model_path, '/%') or model_path = @model_path

查询所有父节点/Select All Parent Node

1
2
3
4
5
6
SET @model_path = (SELECT model_path FROM sso_menu WHERE id = #{id});
SELECT *

FROM sso_menu
WHERE
@model_path like CONCAT(model_path, '%')

3.2 资源控制

这里的资源控制体现在前端对后端的接口访问,包括展开UI后的默认查询,按钮查询,变更与删除等等。


菜单元数据表 menu_meta 中将菜单类型 type=menu 变更为 type=button ,并增加权限标识 (menu_meta.meta_code[‘resource_code’]),对应资源管 ResourceManage/RM 中的 API唯一编码。

前端 Vue/Element-Plus 根据返回菜单(Role->Menu,菜单授权给),获得自己权限 resourceCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
let authResources = []

authResources.includes(data) // test resource code
// export permission.js


export default {
mounted(el, binding) {
const { value } = binding
if(Array.isArray(value)){
let ishas = false;
value.forEach(item => {
if(permission(item)){ // permission.js
ishas = true;
}
})
if (!ishas){
el.parentNode.removeChild(el)
}
}else{
if(!permission(value)){
el.parentNode.removeChild(el);
}
}
}
};
// export auth.js


app.directive('auth', auth)

1
2
<!-- .vue file use directive -->
<el-button v-auth="'uc.resource.delete'" type="primary" @click="">Delete</el-button>

4、资源管理 (ResourceManage/RM)

系统中很多东西都可以按照资源来管理,在此将范围与规模缩小,这里的资源仅仅指代接口(API)资源。

资源管理分为:接口扫描,接口元数据管理,接口由下而上控制管理

4.1 接口扫描

接口扫描是资源管理这个功能保证 有效性 的必要条件,也是整个接口资源管理的基础。

接口扫描就是获得程序可对外提供访问的接口,对大部分WEB服务来说就是获得可用 restful api 接口。接口扫描至少有分为静态接口扫描和运行时接口扫描。

  1. 静态接口扫描(Scan SourceCode):静态接口扫描又分为源代码扫描与代码扫描。源代码扫描为分析 *.java 文件获得接口声明的文本特征,代码扫描即通过反射或字节码分析工具分析接口注解等特征代码。
  2. 运行时接口扫描(Scan Runtime):运行时扫描是通过系统运行时信息,从容器等地方获取最终可用的,已经注册的接口信息。

运行时扫描相比静态扫描可以获得通过编码形式注册的接口信息,但是对于一些响应式或者业务代码动态注入的接口,目前还未做有效管理。

相对来说,运行时接口扫描相对准确性和有效性较高,并且不涉及额外三方类库较为简单。这里采用运行时接口扫描的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping
@Resource
private RequestMappingInfoHandlerMapping handlerMapping;


Map<RequestMappingInfo, HandlerMethod> handlerMethods = handlerMapping.getHandlerMethods();


for (Map.Entry<RequestMappingInfo, HandlerMethod> entity : handlerMethods.entrySet()) {
HandlerMethod value = entity.getValue();
Object bean = value.getBean();
RequestMappingInfo requestMappingInfo = entity.getKey();
Set<RequestMethod> methods = requestMappingInfo.getMethodsCondition().getMethods();
Set<String> patterns = requestMappingInfo.getPatternsCondition().getPatterns();
for (String pattern : patterns) {
for (RequestMethod method : methods) {

// runtime restful api info
}
}

}

上述代码可以获得注册或者说已经被 Spring Framework 管理的接口信息,相对来说比较简单。将上述代码作为类库的方式交由业务微服务引用,并通过 Spring-boot 自动配置注册一个扫描控制接口,以便在微服务接入后启用扫描服务获得对外接口信息。

资源管理服务在扫描获得接口信息后,将信息持久化保存在数据库中,这些数据叫做接口的 元数据

4.2 元数据管理

为了更有效的定义和管理接口元数据,在接口定义的时候增加一个端点接口描述注解。将此注解与代码生成器相结合,在生成模板代码时自动初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Target({ElementType.METHOD})  
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ResourceEndPoint {
/**
* 资源编码
*
* 却资源层级,保持同一层级的唯一性
*
* @return 资源编码
*/
String code();

/**
* 当前层级资源名
*
* @return 资源名
*/
String name();

/**
* 认证模式
*
* 默认基于角色
*
* @return 返回当前资源的认证方式
*/
ResourceAccreditEnum accreditPattern() default ResourceAccreditEnum.ROLE;

/**
* 代码层面的备注信息
*
* @return 备注
*/
String note() default "";

/**
*
* 根据白名单和黑名单单独分组,分组内
* 按优先级由小到大顺序,成功即返回
*
* @return 匹配优先级
*/
int priority() default 10;

/**
* API索引
*
* @return 索引值
*/
int index() default 100;
}

上述注解需要定义在API接口上,对接口进行额外说明以便实现接口元数据更加详细准确且便于管理。同时接口信息分为模板类增删查改的固定实现和根据实际功能模块的自定义实现,所以就需要一定的生成机制尽可能的减少人工的重复操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@ResourceEndPoint(code = "%s.add", name = "新增%s", index = ResourceEndPoint.ADD)  
@PostMapping("/add")
public BaseResponse<String> add(@Valid @RequestBody UserInfoAddReq addRequest){
return super.add(addRequest);
}

@ResourceEndPoint(code = "%s.update", name = "更新%s", index = ResourceEndPoint.UPDATE)
@PostMapping("/update")
public BaseResponse<String> update(@Valid @RequestBody UserInfoEditReq editRequest){
return super.update(editRequest);
}

@ResourceEndPoint(code = "%s.lock", name = "锁定%s", index = 11)
@PostMapping("/lock/{id}")
public BaseResponse<String> lock(@PathVariable("id") Long id){
service.lockUser(id);
return BaseResponse.success();
}
@ResourceEndPoint(code = "%s.resetPassword", name = "重置密码", note = "使用所属客户端重置用户密码")
@PostMapping("/resetPassword/{id}")
public BaseResponse<String> resetPassword(@PathVariable("id") Long id){
service.resetPassword(id);
return BaseResponse.success();
}

上述代码为最终的可使用接口,可以看到存在 Srpring framework 接口声明注解外还需要增加 @ResourceEndPoint 元数据描述注解。如果没有此注解资源扫描框架会根据默认规范生成一系列尽可能准确和可读性强的元数据信息,这些生成信息大多时候是面向开发者。如果让系统日常维护与使用者配置资源访问权限则,这些自动生成的元数据信息就非常不友好,可读性叫较差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@ResourceEndPoint(code = "%s.page", name = "%s分页查询", index = ResourceEndPoint.PAGE)  
@PostMapping("/page")
public PageResponse<${vo}> selectPage(@RequestBody ${query} query){
return super.selectPage(query);
}
@ResourceEndPoint(code = "$%s.list", name = "%s列表查询", index = ResourceEndPoint.LIST,
accreditPattern = ResourceAccreditEnum.DENY)
@PostMapping("/list")
public BaseResponse<List<${vo}>> list(@RequestBody ${query} query) {
return super.list(query);
}

@ResourceEndPoint(code = "%s.add", name = "%s新增", index = ResourceEndPoint.ADD)
@PostMapping("/add")
public BaseResponse<String> add(@Valid @RequestBody ${addReq} addRequest){
return super.add(addRequest);
}
@ResourceEndPoint(code = "%s.update", name = "%s更新", index = ResourceEndPoint.UPDATE)
@PostMapping("/update")
public BaseResponse<String> update(@Valid @RequestBody ${editReq} editRequest){
return super.update(editRequest);
}

上述代码为内置代码生成器中针对 MVC Controller 的生成模板 Controller.ftl,使用 freemaker 框架最终渲染出 XXXController.java 文件。

如上图所示通过扫描获得的元数据信息,算是比较友好的展示系统内可用的权限信息(资源)。对于使用来说权限信息分为三部分:

  1. 权限名(Resource Name):接口的描述信息,用于展示使用
  2. 编码(Resource Code):唯一编码,实际就是权限名,为前端或者后端操作人员选择后的权限标识。最直观的使用就是菜单管理中按钮配置的权限编码。
  3. 资源接口信息(Resource Request):接口实际的请求信息,包括请求URL, HttpMethod等信息。

在此基础上支持已有资源权限的编辑修改,以及新增权限。

新增权限为非常重要的功能,可以使用通配符批量授权,而不是一个一个的选择。

4.3 接口控制

这里说的接口控制更多是指接口本身的自下而上控制。

如上图所示,接口授权模式字段用于通过接口自己的声明信息作为默认配置。接口授权模式是权限访问的最后一道控制手段,实际使用中和角色授权是取交集的关系。

如对于用户的访问,要同时检测角色权限和接口权限。即便角色已授权此接口,但是接口本身已禁用则同样无权访问。由此不难看出接口的授权模式其实在划分上更加偏向底层,偏向开发者。使用场景更多是停用功能,下线某些功能。对于交互式文案来说这时候就不是无访问权限或者访问已禁止,而是功能已禁用或功能已下线等。

也正是因为有此功能,资源权限控制有了自上而下(角色授权)和自上而下(接口授权)的双向控制机制,在使用上则是角色维度不同,如角色授权管理更多的是系统使用者(业务人员),接口授权更多是系统管理者(运维人员)。

5、授权管理 (AccreditationManage/ACM)

上面几点可以说是静态内容,将上面内容结合起来使用就是授权管理的作用。

总的来说授权管理分为常规授权,如角色与用户授权(User -> Role),用户与客户端授权(User -> OauthClient),和权限授权,角色菜单授权(Role -> Menu),角色接口授权(Role -> Resource/API)。

常规的基于角色的访问控制(Role-based access control, RBAC )在这里不做过多说明,菜单权限在配置完成后由前端完全控制只有在必要的权限变更后需要通知前端刷新,因此这里主要讨论后端的权限管理(Resource/API)。

在认证成功后用户获得访问令牌(Access Token),每次请求会持有认证阶段颁发的令牌。授权管理会在请求到达后端时获得令牌,在校验令牌有效性后开始进行资源权限校验。

这里与其说是逻辑与业务问题,更多的反而是实际的工程问题。

5.1 接口数量

接口数量存在资源权限校验很难控制的规模上限,如果处理不好会严重影响系统整体的并发。

对于一个相对成熟与可用的项目其中存在复杂的逻辑一定少不了,对应的数据存储可能会随着业务拓展和规模拓展而快速膨胀。

接下来以目前比较流行且可行性最高的基于数据编程的开发模式进行说明。

基于数据编程或者说是基于数据库编程可以非常直观的与MVC模式融合,所有业务逻辑放在 Service 层,同时作为沟通 Mapper/DaoController 的桥梁。如果某项逻辑过于复杂或者需要引用多个功能,那么就抽象出一个全新的 Service 实体做一个请求和数据的聚合处理。

也正是基于这种相对直观的开发模式,对应的低代码生成器也相对来说较为简单。在通用模式下首先不考虑一个实体数据表(Databse Table)的意义和业务划分,那么其就必须包含必要的数据集操作接口:分页查询/PageQuery新增/Add修改/Update删除/Delete查询/ListQuery可视化查询/DetailQuery批量删除/BatchDelete批量新增/BatchAdd启用/enable禁用/disable 。至少 10个通用接口,如果是业务核心相关实体更是不可避免的增加大量自定义接口。

接口默认授权存在编程候选值,如不分页查询,批量操作。这些接口只有在特定场景下使用,因此默认都是禁用状态。如果使用则需要管理员开启授权。

对于一些我实际参与过的线上运行系统,数据表都在 300 张上下,那么保守估计在不做代码生成器配置时则仅仅对应的业务就有 200 x 10 ~ 2k 约两千个左右的接口。如果不做额外处理将系统管理授权给系统管理员角色,那么不可避免的每次访问都有就要进行一个全局匹配,且很难通过数据库优化和代码缓存优化来减少延迟(这主要是由于 Restful 规范导致)。

1
2
/modeule/function/info/{id}
/modeule/{patch_segment_1}/{Parameter}/Path_Segment_2

对于常规的系统接口定义要么采用JSON Body或JSON Query 传值,或者url后缀传值,针对此类方式都有相对较好的优化匹配模式。但是如果考虑到 Restful 的 path 替换传值后一下子就变得复杂了起来,因为这个 path_segment 可以存在于url的各个片段中,除非在提前知道此类接口并做单独的存储与优化,否则不可避免的要将所有接口全部扫描并匹配一遍。除非从存储角度就进行分类和索引优化从一开始就将匹配范围限制到一个非常小的范围,否则必定严重影响访问效率。

5.2 实际使用

  1. 定制代码生成器:对于非业务数据表,如关联中间表完全没有必要向外保留数据操作接口,逐渐摒弃基于数据编程的开发模式。按需暴露数据操作接口。
  2. 异常地址:如恶意访问不存在地址,或者由于反向代理错误大量请求都转发到不存在的地址,必然造成大量匹配规则失效。
  3. 自定义批量授权:通过配置减少匹配范围,如针对管理员就新建一条 * 的权限开放所有匹配规则。
  4. 受限用户:受限用户存在较为多样的场景,因此建议采用黑白名单(allow/block list)的方式进一步减少匹配范围,同时也减少了UI的操作。

文案

作为系统的基础设施,如果做了足够细致的功能划分,那么在交互性文本(文案)上就必须足够人性化或者说有实际意义。

针对认证问题就压名且给出问题出在那个功能模块,哪个业务或者说逻辑流程上,而不是简单粗暴的提示无权限,或者系统内部错误。

在配置 MessageSource 并全局注入每个子模块的 i18n 配置文件后,让UI层交互返回的信息真正的起到作用,从而让使用者能从逻辑上或者是使用手册上明白问题处在哪里。进而根据引导自己解决问题或者找到相应的上级权限管理员,而不是直接去找运维人员,甚至是找开发人员解决问题。

6、最后

设计并实现一个系统或者功能按照现在很成熟的开发与管理流程,再加上几乎随处可得案例与经验分享已经不是那么困难的事情。
大多数系统的问题还是与实际的用户与最终落实到应用场景上,在移动互联网时代,特别是移动端这一点被强调的非常多。前端虽然有专门的研究人员与大量的交互设计师,但是除了做一个华丽的效果外,真的没有看到像移动端那样的玩耍的交互规则。而像后端系统由于历史遗留问题基本被后端主导,并且伴随着面向数据库编程的这种成熟模式,导致后端开发更加倾向于功能性开发。

我想这也是前端出现各种数据层框架,以及后端出现中台的一个原因。系统最终还是给用户使用,而大部分系统都在强行交用户如何使用,甚至改变用户的一贯思维方式和使用逻辑,这必定在很多时候引起用户的反感以及不可预测的操作方式,这时候如果没有准确且逻辑清晰的文案说明与逻辑引导,最终会让围绕系统的各方面相关人员都疲于奔命。