基于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 权限
REST:资源和状态转换
(该节内容来源基于RESTful API 怎么设计用户权限控制?)
Representational State Transfer,简称REST,是Roy Fielding博士在2000年他的博士论文中提出来的一种万维网软件架构风格,目的是便于不同软件/程序在网络(例如互联网)中互相传递信息。
REST比较重要的点是资源和状态转换,
所谓”资源”,就是网络上的一个实体,或者说是网络上的一个具体信息。它可以是一段文本、一张图片、一首歌曲、一种服务,总之就是一个具体的实在。
而 “状态转换”,则是把对应的HTTP协议里面,例如四个表示操作方式的动词分别对应四种基本操作:
- GET,用来浏览(browse)资源
- POST,用来新建(create)资源
- PUT,用来更新(update)资源
- DELETE,用来删除(delete)资源
资源的分类及操作
清楚了资源的概念,然后再来对资源进行一下分类,我把资源分为下面三类:
- 私人资源 (Personal Resource )
- 角色资源 (Roles Resource )
- 公共资源 (Public Resource )
“私人资源”:是属于某一个用户所有的资源,只有用户本人才能操作,其他用户不能操作。例如用户的个人信息、订单、收货地址等等。
“角色资源”:与私人资源不同,角色资源范畴更大,一个角色可以对应多个人,也就是一群人。如果给某角色分配了权限,那么只有身为该角色的用户才能拥有这些权限。例如系统资源只能够管理员操作,一般用户不能操作。
“公共资源”:所有人无论角色都能够访问并操作的资源。
而对资源的操作,无非就是分为四种:
- 浏览 (browse)
- 新增 (create)
- 更新 (update)
- 删除 (delete)
策略/过滤器
工作原理是大同小异的,主要是在一条HTTP请求访问一个Controller下的action之前进行检测。所以在这一层,我们可以自定义一些策略/过滤器来实现权限控制。
下面排版顺序对应Policy的运行顺序
- SessionAuthPolicy:检测用户是否已经登录,用户登录是进行下面检测的前提。
- ResourcePolicy:检测访问的资源是否存在,主要检测Source表的记录
- PermissionPolicy:检测该用户所属的角色,是否有对所访问资源进行对应操作的权限。
- OwnerPolicy:如果所访问的资源属于私人资源,则检测当前用户是否该资源的拥有者。
如果通过所有policy的检测,则把请求转发到目标action。
READ风格权限设计的实现
DB 设计
系统的业务场景比较简单,用户只需要一个角色即可,角色和权限还是多对多
接口&权限表
接口举例:
权限表举例:这块我把 资源对象 和权限表进行了合并,Resource 为parent_code =0
,Permission 为parent_code!=0
的,资源的权限是parent_code做区分。
其中url 是,Ant风格的pattern
1 2 3 4 5
| 字符wildcard 描述
? 匹配一个字符 * 匹配0个及以上字符 ** 匹配0个及以上目录directories
|
权限控制代码实现(java-spring)
用户登录后,会从后台取出所有权限集合 和 用户权限集合,会根据这两个集合进行处理,一个是处理为后端需要的权限集合,后续拦截器根据这份缓存进行权限拦截;第二个是会给出一份给到前端,前端根据这份权限信息,对页面元素进行disabled
或hidden
处理
初始化权限集合:
登录后,初始化权限集合,且分别转换为 前端所需要的结构和后端所需要的权限结构
后端权限集合结构,这个集合为全量的权限集合,只是在原权限的基础上,增加一个字段,用户对该资源的某某操作有权限,则给这条记录打上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);
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()); 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());
for (BasePermission resource : resources) { if (resource.getId() != null && resource.getCode() != null && StringUtils.isNotEmpty(resource.getCode())) { List<BasePermission> _all_permissions = allPermissions.stream() .filter(p -> resource.getCode().equals(p.getParentCode())) .collect(Collectors.toList()); 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()); } 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); HttpSession session = request.getSession(); try { BaseUserInfo userInfo = (BaseUserInfo) session.getAttribute(Constant.LOGIN_USERINFO); if (userInfo != null) { String _url = uri.replace(contextPath, "");
List<PermissionVm> permissions = (List<PermissionVm>) session.getAttribute(Constant.SESSION_CURRENT_BACK_PERMISSION);
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; }
if (_perms.size() > 1) { logger.warn("出现权限判别 重复的url:" + uri + ",method:" + method); _perms.forEach(p -> logger.warn("出现权限判别 重复的权限集合:" + ":" + p.getCode() + ":" + p.getUrl() + ":" + p.getMethod())); } 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
|
public class AntPathMatcherUtil { private static final PathMatcher MATCHER = new AntPathMatcher();
public static boolean testUrl(String pattern, String url){ if(StringUtils.isNotEmpty(pattern) && StringUtils.isNotEmpty(url)){ return MATCHER.match(pattern,url); } return false; }
public static boolean isInExcludeUrlSet(Set<String> urlPatternSet, String url){ if(urlPatternSet ==null || urlPatternSet.size() ==0){ return false; } for (String pattern : urlPatternSet) { if(MATCHER.match(pattern,url)){ 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) { 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 怎么设计用户权限控制?