基于RBAC的Rest抽象资源权限控制设计及实现

基于spring boot的一个小项目,前后端分离,采用REST风格的接口设计,在做权限设计时觉得以前的权限控制实现比较繁琐,一个Action就对应一条Permission。所以想着接口可以走REST风格,权限为什么不可以呢?比如称作READ风格的权限控制(Representational Authorization Design)……

REST早已不是新鲜事物,基于REST的权限控制网上也是有现成的,基于RESTful API 怎么设计用户权限控制?写得很棒,所以基于这篇文章的设计思路做了个实现(java)。本文的前半段,主要是引用文章阐述设计思路,后半部分主要为程序code实现。

其中后端自定义一个过滤器做权限过滤,用到了spring util包下的AntPathMatcher做权限规则匹配;前端也就一个directive就搞定了。技术上并没有引用什么技术框架,关键还是抽象思维且将设计思路转化为程序上code。

传统的权限配置 vs 权限被抽象成资源和状态

1
2
3
4
5
/getUsersGroups?id=1
/getUsersExpress
/getUsersLables?eventId=xxx
/createUsers?name=xxx&age=18
/updateEvent?id=xx

vs

1
2
3
GET /users/** 表示 对 users 资源 及events 附属的资源 有获取/浏览的权限
POST /users 表示 对 users 资源 有新增的权限
PUT /users/* 表示 对 users 有 修改的权限

基础概念

RBAC:基于角色的访问控制

RBAC(Role-Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般者是多对多的关系。(如下图,图片来源

用户 n:1/n 角色角色 n:n 权限

db-design.png

REST:资源和状态转换

(该节内容来源基于RESTful API 怎么设计用户权限控制?)

Representational State Transfer,简称REST,是Roy Fielding博士在2000年他的博士论文中提出来的一种万维网软件架构风格,目的是便于不同软件/程序在网络(例如互联网)中互相传递信息。

REST比较重要的点是资源和状态转换,
所谓”资源”,就是网络上的一个实体,或者说是网络上的一个具体信息。它可以是一段文本、一张图片、一首歌曲、一种服务,总之就是一个具体的实在。
而 “状态转换”,则是把对应的HTTP协议里面,例如四个表示操作方式的动词分别对应四种基本操作:

  • GET,用来浏览(browse)资源
  • POST,用来新建(create)资源
  • PUT,用来更新(update)资源
  • DELETE,用来删除(delete)资源
    rest

资源的分类及操作

清楚了资源的概念,然后再来对资源进行一下分类,我把资源分为下面三类:

  1. 私人资源 (Personal Resource )
  2. 角色资源 (Roles Resource )
  3. 公共资源 (Public Resource )

“私人资源”:是属于某一个用户所有的资源,只有用户本人才能操作,其他用户不能操作。例如用户的个人信息、订单、收货地址等等。
“角色资源”:与私人资源不同,角色资源范畴更大,一个角色可以对应多个人,也就是一群人。如果给某角色分配了权限,那么只有身为该角色的用户才能拥有这些权限。例如系统资源只能够管理员操作,一般用户不能操作。
“公共资源”:所有人无论角色都能够访问并操作的资源。

而对资源的操作,无非就是分为四种:

  1. 浏览 (browse)
  2. 新增 (create)
  3. 更新 (update)
  4. 删除 (delete)

策略/过滤器

工作原理是大同小异的,主要是在一条HTTP请求访问一个Controller下的action之前进行检测。所以在这一层,我们可以自定义一些策略/过滤器来实现权限控制。

下面排版顺序对应Policy的运行顺序

  1. SessionAuthPolicy:检测用户是否已经登录,用户登录是进行下面检测的前提。
  2. ResourcePolicy:检测访问的资源是否存在,主要检测Source表的记录
  3. PermissionPolicy:检测该用户所属的角色,是否有对所访问资源进行对应操作的权限。
  4. OwnerPolicy:如果所访问的资源属于私人资源,则检测当前用户是否该资源的拥有者。

如果通过所有policy的检测,则把请求转发到目标action。

rest

READ风格权限设计的实现

DB 设计

系统的业务场景比较简单,用户只需要一个角色即可,角色和权限还是多对多
rest

接口&权限表

接口举例:
rest

权限表举例:这块我把 资源对象 和权限表进行了合并,Resource 为parent_code =0 ,Permission 为parent_code!=0的,资源的权限是parent_code做区分。

其中url 是,Ant风格的pattern

1
2
3
4
5
字符wildcard    描述
   ?         匹配一个字符
  *         匹配0个及以上字符
  **         匹配0个及以上目录directories

rest

权限控制代码实现(java-spring)

用户登录后,会从后台取出所有权限集合 和 用户权限集合,会根据这两个集合进行处理,一个是处理为后端需要的权限集合,后续拦截器根据这份缓存进行权限拦截;第二个是会给出一份给到前端,前端根据这份权限信息,对页面元素进行disabledhidden处理

初始化权限集合:

登录后,初始化权限集合,且分别转换为 前端所需要的结构和后端所需要的权限结构

后端权限集合结构,这个集合为全量的权限集合,只是在原权限的基础上,增加一个字段,用户对该资源的某某操作有权限,则给这条记录打上1,否则为0。

1
2
3
4
权限A urlA GET 1
权限B urlA POST 1
权限C urlC GET 0
权限D urlD GET 1

前端权限集合,给的也是全量的权限集合,不过不是列表形式,而是转化为了对象-权限的json 格式,也方便前端对页面元素的处理(判断0,1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"permissions": {
"books": {
"create": 1,
"update": 1,
"share": 1,
"delete": 1,
"browse": 1
},
"account": {
"resetPwd": 1,
"create": 0,
"update": 1,
"delete": 1,
"browse": 1
}
},

用户登录,获取全量权限和用户权限,分别对其进行前后端结构转化,再存入缓存中(session,redis…)。别跟我提rest为啥用session,你们可以用token(jwt…)

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
@ApiOperation(value = "用户登录", notes = "用户登录")
@ApiImplicitParam(name = "model", value = "密码,请传蜜文", required = true, paramType = "body",
dataType = "LoginUserVo")
@RequestMapping(value = "/login", method = RequestMethod.POST)
public BaseResult<Map<String, Object>> doLogin(@RequestBody LoginUserVo model) throws ParameterException {
String username = model.getUsername();
String password = model.getPassword();
BaseUserInfo baseUserInfo = authService.getAuthUser(username, password);
boolean flag = false;
Map<String, Object> result = new HashMap<String, Object>();
if (baseUserInfo != null) {
flag = true;
result.put("userinfo", baseUserInfo);
session.setAttribute(Constant.LOGIN_USERINFO, baseUserInfo);
// 权限处理
List<BasePermission> allPermissions = authService.getAllPermissions();
List<BasePermission> userPermissions = authService.getPermissionListByRoleId(baseUserInfo.getRoleid());
//转化为后端所需要的权限集合。方便做权限过滤
List<PermissionVm> permissionVms = authService.transToBackendPermissions(allPermissions, userPermissions);
session.setAttribute(Constant.SESSION_CURRENT_BACK_PERMISSION, permissionVms);
//转化为前端所需要的权限集合。方便session api 中直接取
Map<String, Map<String, Integer>> permissions
= authService.transToFrontendPermissions(allPermissions, userPermissions);
if (permissions == null) {
permissions = new HashMap<>();
}
session.setAttribute(Constant.SESSION_CURRENT_FRONT_PERMISSION, permissions);
result.put("permissions", permissions);
}
result.put("success", flag);
return BaseResult.getInstance(ResponseCode.SUCCESS_0, result);
}

转化为后端所需要的格式:

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
@Override
public List<PermissionVm> transToBackendPermissions(List<BasePermission> allPermissions, List<BasePermission> userPermissions) {
List<PermissionVm> permissionVms = new ArrayList<>();
if (allPermissions == null
|| allPermissions.size() == 0 ) {
return permissionVms;
}
allPermissions.stream().forEach(p -> {
//判断是否有权限
boolean isContain = userPermissions ==null ? false: userPermissions.stream()
.filter(x -> p.getId().longValue() == x.getId().longValue())
.findAny().isPresent();
PermissionVm model = new PermissionVm();
model.setId(p.getId());
model.setParentCode(p.getParentCode());
model.setCode(p.getCode());
model.setUrl(p.getUrl());
model.setMethod(p.getMethod());
//增加的Contain字段 *,有权限为1 ,无该权限为0
model.setContain(isContain ? (byte) 1 : (byte)0);
permissionVms.add(model);
});
return permissionVms;
}

转化为前端所需要的格式:

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
@Override
public Map<String, Map<String, Integer>> transToFrontendPermissions(
List<BasePermission> allPermissions,
List<BasePermission> permissions) {
Map<String, Map<String, Integer>> returnMap = new HashMap<>();
if (allPermissions == null
|| allPermissions.size() == 0) {
return null;
}
List<BasePermission> resources = allPermissions.stream()
.filter(p -> Constant.PERMISSION_RESOURCE_TYPE.equals(p.getParentCode()))
.collect(Collectors.toList());
//[{resource:{create:1,update:1,delete:0}},{resource:{create:1,update:1}}]
for (BasePermission resource : resources) {
if (resource.getId() != null && resource.getCode() != null
&& StringUtils.isNotEmpty(resource.getCode())) {
//封装 前端所需结构
//1. 从 全量 权限表中取出 resource 相关的权限
List<BasePermission> _all_permissions = allPermissions.stream()
.filter(p -> resource.getCode().equals(p.getParentCode()))
.collect(Collectors.toList());
//2. 从用户权限取出 resource 相关的权限
List<String> _user_permissions = new ArrayList<>();
if(permissions != null){
_user_permissions = permissions.stream()
.filter(p -> resource.getCode().equals(p.getParentCode()))
.map(p -> p.getCode()).collect(Collectors.toList());
}
//2. 循环 该资源的 权限集,根据用户的权限集进行判断
Map<String, Integer> _singlePermission = new HashMap<>();
for (BasePermission p : _all_permissions) {
if (_user_permissions.contains(p.getCode())) {
_singlePermission.put(p.getCode(), 1);
} else {
_singlePermission.put(p.getCode(), 0);
}
}
returnMap.put(resource.getCode(), _singlePermission);
}
}
return returnMap;
}

权限拦截器:

主要实现了 1.SessionAuthPolicy:检测用户是否已经登录,用户登录是进行下面检测的前提。3. PermissionPolicy:检测该用户所属的角色,是否有对所访问资源进行对应操作的权限。逻辑参考注释。

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
53
54
55
56
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
String method = request.getMethod();
logger.info("AuthPermissionPolicy uri:" + uri + " -- method:" + method);
// todo 排除配置好的exclude url set
HttpSession session = request.getSession();
try {
BaseUserInfo userInfo = (BaseUserInfo) session.getAttribute(Constant.LOGIN_USERINFO);
if (userInfo != null) {
// 权限拦截。在all权限中,不在user已分配的权限中,则权限验证失败
// uri:/ark/events/1/properties -- method:GET
// 1. 剔除contextPath
String _url = uri.replace(contextPath, "");
List<PermissionVm> permissions = (List<PermissionVm>) session.getAttribute(Constant.SESSION_CURRENT_BACK_PERMISSION);
// 排除非空
// method 匹配
// url 规则匹配
List<PermissionVm> _perms = permissions.stream()
.filter(p -> StringUtils.isNotEmpty(p.getMethod())
&& p.getMethod().equals(method)
&& StringUtils.isNotEmpty(p.getUrl())
&& AntPathMatcherUtil.testUrl(p.getUrl(), _url)).collect(Collectors.toList());
//如果不存在权限表中,则不做权限校验
if (_perms == null || _perms.size() == 0) {
return true;
}
//如果存在权限表中
//如果perms 判别size >1 ,则权限配置可以改进下
//例如同时匹配 /events/** 和 /events/virtual/* ,这时候如果严格要求可以考虑最长匹配规则
if (_perms.size() > 1) {
logger.warn("出现权限判别 重复的url:" + uri + ",method:" + method);
_perms.forEach(p -> logger.warn("出现权限判别 重复的权限集合:"
+ ":" + p.getCode() + ":" + p.getUrl() + ":" + p.getMethod()));
}
// 只要_perms中存在 对应的权限,则通过权限校验,否则返回false
for (PermissionVm pvm : _perms) {
if (pvm.getContain() == (byte) 1) {
return true;
}
}
BusinessUtil.sendFailedMessage(response, ResponseCode.CODE_104_UN_AUTH);
return false;
}
BusinessUtil.sendFailedMessage(response, ResponseCode.CODE_106_SESSION_TIMEOUT);
} catch (Exception e) {
logger.error("AuthPermissionPolicy-exp发生异常", e);
}
return false;
}

其中判断request.url 是否符合在权限表配置好的pattern

主要用到了Spring自带的 的org.springframework.util.AntPathMatcher

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
/**
* Description:
*
* @author shenlongguang<https://github.com/ifengkou>
* @date: 2018/4/13 下午7:41.
*/
public class AntPathMatcherUtil {
private static final PathMatcher MATCHER = new AntPathMatcher();
/**
* 按照pattern规则匹配URL,匹配成功返回true
* @param pattern
* @param url
* @return
*/
public static boolean testUrl(String pattern, String url){
if(StringUtils.isNotEmpty(pattern) && StringUtils.isNotEmpty(url)){
return MATCHER.match(pattern,url);
}
return false;
}
/**
* 是否在排除的集合中
* @param urlPatternSet
* @param url
* @return
*/
public static boolean isInExcludeUrlSet(Set<String> urlPatternSet, String url){
//如果集合为空,直接返回false
if(urlPatternSet ==null || urlPatternSet.size() ==0){
return false;
}
for (String pattern : urlPatternSet) {
if(MATCHER.match(pattern,url)){
//如果匹配上了,返回true
return true;
}
}
return false;
}
}

前端页面的权限控制

vue-directive 创建:

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
import Vue from 'vue'
import io from '@/module/io'
import { get } from 'lodash'
let permission = {}
export default {
// 获取指定权限
get (path) {
//在权限对象中,获取是否有{path}权限,有返回1,否则返回0
if (path) return get(permission, path, 0)
return permission
},
// 获取权限列表。登录后,在页面加载时调用,获取权限列表
requestPermission () {
return new Promise((resolve, reject) => {
io.get('session/permission').then(res => {
let data = res.datas
permission = data
this.directive()
resolve(data)
})
})
},
_setAttr (el) {
let dom = el
dom.setAttribute('disabled', 'disabled')
dom.title = '当前操作无权限'
},
// 绑定权限指令
directive () {
let _this = this
Vue.directive('ps', {
bind: function (el, binding, vnode) {
let path = binding.arg ? binding.arg.replace(/-/g, '.') : null,
value = _this.get(path),
tagName = el.tagName.toLowerCase()
if (value !== 1) tagName === 'button' || tagName === 'input' ? _this. _setAttr(el) : el.style.display = 'none'
}
})
}
}

登录成功后初始化获得权限集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import permission from '@/module/permission'
new Vue({
methods: {
initApp () {
permission.requestPermission()
//......
},
//.......
},
created () {
this.initApp()
}
})

页面元素处理:

资源:account 权限:brower。如果该用户没有account的浏览权限,则对该对象做el.style.display = 'none'操作

1
<a href="/#/user/account" v-ps:account-browse><span>成员管理</span></a>

资源:account 权限:create。如果获得的权限信息中 account:{create:0} 则对该button 做disabled。

1
<v-button v-ps:account-create type="primary" shape="circle" @click="addEvent">添加成员</v-button>

总结

对程序员最大的挑战,并不是能否掌握了哪些编程语言,哪些软件框架,而是对业务和需求的理解,然后在此基础上,把要点抽象出来,写成计算机能理解的语言。

参考

基于RESTful API 怎么设计用户权限控制?

坚持原创技术分享,您的支持将鼓励我继续创作!
分享