Skip to main content

授权

授权指认证成功后系统对主体指定资源执行指定操作的权限控制。

基本概念#
  • 主体(subject):执行操作的主体,通常是用户(user)、也可以是程序。
  • 角色(role):权限的集合,同一角色的用户将用有一组相同的权限。
  • 权限(privilege):权限定了允许哪些用户、角色对本哪些资源执行哪些操作
  • 资源(resourse):操作的对象。在 REST 世界中在表现层以 URI 形式呈现。
  • 资源许可(resourse permission):权限定了允许哪些用户对本哪些资源执行哪些操作。

权限控制策略#

权限控制策略有 3 种:基于主体属性基于角色基于资源许可

对于大部分互联网产品来说 C 端只有单一角色,和 B 端的协作关系也极其简单,(工具类的产品可能很复杂,如 SaaS 型 OA 系统),可采用基于主体属性的策略; B 端则通常是一个典型的企业级应用场景,基于角色的权限控制策略是一个不错的选择。基于资源许可的策略是为了满足更加细化的需求。

基于用户属性#

基于主体属性来实现权限控制适合较简单的权限关系。例如,一款在线服务产品分普通和 VIP 两种用户。普通用户能使用部分功能,VIP用户能使用所有功能。

这里需要强调 简单的权限关系,站在业务的角度 VIP 用户所享有的特权背后可能是极其庞大复杂的业务逻辑,但站在权限关系的角度这仍然是一个简单模型,用一个属性区分即可。

下面通过一个例子来说明如何通过主体属性来实现权限控制。

业务场景

某在线云笔记产品,VIP 用户可以查看笔记历史版本(用于找回以前的版本)。
'王强''普通用户''李刚''VIP' 用户。

API 及 Policy

APIGET /accounts/:account/note/history_versions
Business Policy:{ user_type : 'vip'} //语义:VIP 用户可以调用此接口
王强 GET /accounts/王强/note/history_versions //403 Forbidden,王强 不是 VIP 用户
李刚 GET /accounts/李刚/note/history_versions //200 OK 数据获取成功

基于角色#

较复杂的权限关系适合采用基于角色的权限控制。例如,一个企业 ERP 系统中有 员工、项目经理、部门经理、总经理等角色划分,他们在系统中拥有不同的权限,相互协作。

下面通过一个例子来说明如何通过角色来实现权限控制。

业务场景

'开发组''产品组''技术部' 下的项目组。
'王强''开发组' 的成员;'李刚''开发组''项目经理'
'李丽''产品组' 的成员。
'李永强''技术部''部门经理'
'王强' 发起一个请假审批流程,该流程需要其所属组的 '项目经理' 以及上级 '部门经理' 的审批。
王强、李刚、李丽、李永强 认证通过后 token 中所含的用户信息如下:
{ id : 1 , name : '王强' , groups : [{ id : '开发组'}]}
{ id : 2 , name : '李刚' , groups : [{ id : '开发组'} , roles :['manager']]}
{ id : 3 , name : '李丽' , groups : [{ id : '产品组'}]}
{ id : 4 , name : '李永强' , groups : [{ id : '技术部'} , roles :['manager']]}

API 及 Policy

发起请假申请
APIPOST /groups/:group_id/requests //语义:向某个组织节点中申请列表中增加一条记录
Role Policy:{ group : 'group_id' } //语义:发起请求的用户必须是 URI 中 group 的成员。
王强 POST /groups/开发组/requests //200 OK 处理成功
李丽 POST /groups/开发组/requests //403 Forbidden,李丽 不是 开发组 的成员,不能向 开发组 申请列表中增加一条记录
审批(项目经理审批、部门经理审批)
APIPATCH /groups/:group_id/requests/:request_id/approval
Policy:{ group : 'group_id', roles : ['manager'] } //语义:进行审批的用户必须是 URI 中 group 的经理。
Policy:{ group : 'group_id', privileges : ['request_approval'] }
李刚 PATCH /groups/开发组/requests/2DC87612EK520411B/approval //200 OK 处理成功
王强 PATCH /groups/开发组/requests/2DC87612EK520411B/approval //403 Forbidden,王强 不是 开发组 的项目经理
李永强 PATCH /groups/技术部/requests/2DC87612EK520411B/approval //200 OK 处理成功

资源许可#

有些业务场景要求根据资源本身的访问许可来实现访问控制,这是更加细化的访问控制。例如,一个重要的报表文件被要求设置成只能某几个部门经理有权限访问,这个业务场景是基于主体属性、角色策略无法满足的。另外的一个非常典型的例子就是微信朋友圈发帖时可以设置谁可以看(公开、秘密、部分可见、不给谁看)。

下面通过一个例子来说明如何通过资源许可实现权限控制。

业务场景

'王强''李丽''李刚'是一家房产公司某门店的 3 位房产经纪人。
房源信息中重要的部分(房主电话等)只能被拥有该信息的经纪人查看修改。
由于业务需要,'王强' 要把这条房源信息中的重要部分共享给 '李丽' 查看,但不能修改。

API 及 Permission

APIGET /account/:account_id/housing/housing_id/owner_tel
资源许可数据结构:
object //资源数据体
{
...
permissions: {
<action>: [ String ]
// <action> 系统预定义操作:GET、UPD、DEL;
// [ String ] 公开对象的 ID 的列表
},
...
}
李丽 POST /account/王强/housing/2DC87612EK520411B/owner_tel //200 OK 获取数据成功
李刚 POST /account/王强/housing/2DC87612EK520411B/owner_tel //403 Forbidden
{
owner_tel : 186XXXXXXXX,
permissions: {
GET: [ { id : 1 , name : '李丽'}]
}

权限控制实现#

权限控制策略实现方式未通过检查时的响应
基于主体属性基于 subject_property policy 的权限检查403 Forbidden NOT_AUTHORIZED
没有被授权访问当前资源
基于角色基于 role policy 的权限检查403 Forbidden NOT_AUTHORIZED
没有被授权访问当前资源
基于资源许可基于 resourse 数据主体中 permission 定义的权限检查403 Forbidden NOT_AUTHORIZED
没有被授权访问当前资源
其他具体的业务逻辑代码400 Bad Request INVALID_REQUEST
没有被授权访问当前资源

设计与实现#

设计一个权限检查过滤器,访问控制权限检查逻辑为:

  1. 校验用户令牌,取得用户信息;
    • 请求发送者的身份(用户的 ID、用户的类型、加入的组织及在各组织中的角色等信息)
    • 请求操作的资源
  2. 根据路由定义从 HTTP 请求路径中解构路径参数;
  3. 根据权限检查配置,使用路径参数构造权限检查过滤器的参数;
  4. 执行访问权限检查。
    • 用户类型检查;
    • 所有者检查;
    • 用户所属群组检查;
    • 用户所属群组角色检查;
    • 资源访问许可检查。
'use strict';
const mongoose = require('mongoose');
const ItemModel = mongoose.model('Item');
const OrderModel = mongoose.model('Order');
const intersection = require('lodash/intersection');
/**
* 用户操作权限检查。
* @param {object} user 访问令牌携带的用户信息
* @param {string} user.id 用户 ID
* @param {string} user.type 用户类型,默认为普通用户
* @param {[object]} user.groups 所属群组角色的映射表
* @param {object} permission 资源的访问许可设置
* @param {string} [permission.userType] 用户的类型,设置时,用户类型必须与规定的类型相同
* @param {string} [permission.ownerId] 所有者 ID,设置时,用户 ID 必须与规定的所有者 ID 相同
* @param {string} [permission.groupId] 群组 ID,设置时,用户必须已加入该群组
* @param {[string]} [permission.roles] 限制的角色,设置时,用户必须为规定的角色
* @param {string} [permission.resourceType] 资源的类型
* @param {string} [permission.resourceId] 资源的 ID
* @param {string} [permission.actionType] 操作的类型
*/
exports.checkUserPrivilege = async function(user, permission) {
if (!user) {
throw new Error('尚未登录'); // 401 Unauthorized
}
// 用户已加入组织的 ID 的列表
let userGroupIDs = Object.keys(user.groups);
// 检查内容:
// 1、用户的类型;
// 2、用户是否为资源所有者;
// 3、用户是否为资源所属组织的成员;
// 4、用户是否为资源所属组织的指定角色的成员。
if ((permission.userType && user.type !== permission.userType)
|| (permission.ownerId && user.id !== permission.ownerId)
|| (permission.groupId
&& userGroupIDs.indexOf(permission.groupId) < 0)
|| (permission.groupId
&& permission.roles
&& permission.roles.indexOf(user.groups[permission.groupId]) < 0)) {
throw new Error('无权执行此操作'); // 403 Forbidden
}
// 如果是对某一资源执行操作,则检查该资源是否向当前用户(或用户所属组织)开放了相应操作的权限
if (permission.resourceId) {
let Model = null;
// 取得指定类型资源的数据模型
// 提示:泛化的数据结构设计有助于减少不同数据模型的定义
switch (permission.resourceType) {
case 'item': Model = ItemModel; break;
case 'order': OrderModel = ItemModel; break;
// ……
}
// 取得资源的许可信息
let resource = await Model
.findOne({ id: permission.resourceId })
.select('permissions')
.lean();
if (!resource) {
throw new Error('指定的资源不存在'); // 404 Not Found
}
if (resource.permissions
&& resource.permissions[permission.actionType]
&& intersection(
resource.permissions[permission.actionType],
userGroupIDs.concat(user.id)
).length < 0) {
throw new Error('无权执行此操作'); // 403 Forbidden
}
}
// 通过权限检查
}

GET /bbs/posts/post_id
POST /bbs/posts
角色#
{
//这个业务逻辑存储在哪里? 代码里?
//静态化怎么办?权限怎么Check
//论坛业务中帖子一旦关闭,除帖子的发布者、论坛管理员其它用户无法查看
roles: [ 'level1','level2','level3' ]
}
业务逻辑#
{
if (post.status = "closed") {
if ( post.ownerId = user.id || user.role = "admin") {
//do something
} else {
// 401
}
}
}

配置路由时,我们可以为每一个路由设置一个权限检查的参数,路由器根据客户端请求的路径以及这个权限检查配置参数解构出上述权限检查过滤器的 permission 参数,并将其与访问令牌携带的用户信息一起传递给权限检查过滤器函数。

例如,消费者取消未支付订单接口的路由定义及其权限检查配置参数为:

DELETE /users/:user_id/orders/:order_id
{
ownerId: 'user_id' // 将路径参数 user_id 作为权限检查过滤器函数的 permission.ownerId 参数
}

店员修改订单应付金额接口的路由定义及其权限检查配置参数为:

这里我们增加一个需求:店员在店铺的角色必须为销售员(salesman

PATCH /stores/:store_id/customer-orders/:order_id
{
groupId: 'store_id', // 将路径参数 store_id 作为权限检查过滤器函数的 permission.groupId 参数
roles: [ 'salesman' ] // 用户必须为所属组织的 salesman 角色
}

管理员取消已支付订单的路由定义及其权限检查配置参数为:

DELETE /orders/:order_id
{
userType: 'admin' // 用户的类型必须为管理员
}

下面是店员管理商品的接口的权限设计。

创建商品的路由定义及其权限检查配置参数:

POST /stores/:store_id/items
{
groupId: 'store_id', // 必须为店铺成员
roles: [ 'stock' ] // 必须为库存管理员
}

更新商品的路由定义及其权限检查配置参数:

PUT /stores/:store_id/items/:item_id
{
groupId: 'store_id', // 必须为店铺成员
roles: [ 'stock' ], // 必须为库存管理员
resourceType: 'item', // 操作资源类型为商品
resourceId: 'item_id', // 操作资源 ID 为路径参数中的 item_id
actionType: 'update' // 商品必须已向当前用户开放更新操作的权限
}