最近做一个基于nodejs的权限管理,查阅了一两天,发现大致是这样的:
passportjs
node-oauth
rbac
node_acl
express_acl
connect-roles
需求
- 按照模块,页面,API等级别做权限控制,暂时不需要做到按钮级别
- 主要程序开发完毕,需要侵入少
- 存储主要考虑redis
- 自己开发管理页面,方便自定义和维护
选取原则
- 轻量级别
passportjs太强大,大到怕怕 - 文档清晰(示例|API)
node_acl的readme 我只能说,真的是很友好,API也不多但是都非常get到点 - 好上手
- 容易扩展
- 功能强大或者适用
- 代码侵入少
最讨厌到处修改代码 - 人气指数相对比较高
最后选择了node_acl,主要是
- 人气相对比较高,大约2000star,
- 其次功能本身很独立,提供内存,mongo和redis三种存储方式
- API简单好用
- 文档好读
- 源码不多,方便去自定义和扩展
- 我们主要程序开发完毕,需要侵入少,研究后发现node_acl应该可以
确认node_acl后,就开始研究一些小细节和设计,说这么多,就是自己写一点代码进行接口功能测试。
问题列表:
- 权限继承
addRoleParents满足需求,确实好用。比如guest, user ,admin三个Role, user集成guest, admin集成user。然后对不同的Role进行权限配置。还是不错的 - Resource 不支持同配
这是什么意思,比如消息有下面几个路径, /msg/delete, msg/add, /msg/list,你就必须一条一条的配置,是不是很狗血
Added the possibility to have a wildcard in resource name - 不提供所有的Role查询的API
我进入后,居然不知道有多少种Role - 删除角色后,数据有残存
- 需要引入模块来关联页面或者API
- 初始化需要有超级管理员
- 默认设置,全部允许?全部不允许访问?
- 是否引入目录继承关系
- 。。。。。。
这里扒拉扒拉写这么多,很多只要API能做到,剩下的就是设计问题。麻烦的问题来了
- Resource不支持通配
- 不提供所有的Role查询的API
这两个底层基本的功能不支持,还玩个蛋。
冷静,冷静,我们打开源码,会发现,插件一共就7个js(版本0.4.11)
- acl.js
核心之核心文件,暴露Role,Resource, Permission等等的API - backend.js
backend API定义,并没实际作用 - contract.js
参数验证js - memory-backend.js
内存中存储 - mongodb-backend.js
mongodb存储 - redis-backend.js
redis存储 - index.js
默认文件
三种backend都是存储数据的,那我们先导出数据来看一看:
关于怎么导出redis
- 安装redis-dump
- edis-dump -h 127.0.0.1 -d 0 --json > c:\db.json
我们看一看导出的文件
{
"acl_allows_/@guest": {
"type": "set",
"value": [
"*"
]
},
"acl_allows_/about@guest": {
"type": "set",
"value": [
"*"
]
},
"acl_allows_/index@guest": {
"type": "set",
"value": [
"*"
]
},
"acl_meta@roles": {
"type": "set",
"value": [
"guest"
]
},
"acl_meta@users": {
"type": "set",
"value": [
"1024"
]
},
"acl_resources@guest": {
"type": "set",
"value": [
"/",
"/about",
"/index"
]
},
"acl_roles@user": {
"type": "set",
"value": [
"1024"
]
},
"acl_users@1024": {
"type": "set",
"value": [
"user"
]
}
}
- acl是前缀,在初始化acl的时候可以设置
var acl = require('acl');
// Using redis backend
acl = new acl(new acl.redisBackend(redisClient, 'acl'));
- acl_meta@roles,acl_meta@users,acl_meta@users
acl_meta@roles就表示存储的所有的Role, 其他的同理
翻到代码acl.js 看看系统是怎么取某个用户的Roles的
/**
userRoles( userId, function(err, roles) )
Return all the roles from a given user.
@param {String|Number} User id.
@param {Function} Callback called when finished.
@return {Promise} Promise resolved with an array of user roles
*/
Acl.prototype.userRoles = function(userId, cb){
return this.backend.getAsync(this.options.buckets.users, userId).nodeify(cb);
};
this.options.buckets.users是个什么鬼,翻到顶部,
options = _.extend({
buckets: {
meta: 'meta',
parents: 'parents',
permissions: 'permissions',
resources: 'resources',
roles: 'roles',
users: 'users'
}
}, options);
this.options.buckets.users: 就是users文本,那么联想这几个参数
'acl','users' ,'1024', 再看看,你是不是很惊喜,很意外。
"acl_users@1024": {
"type": "set",
"value": [
"user"
]
}
其实很简单,redis-backend.js里面有个方法叫做 bucketKey,专门用户拼接存储的key,
所以,你想获得什么数据,思路就很简单了,
bucketKey : function(bucket, keys){
var self = this;
if(Array.isArray(keys)){
return keys.map(function(key){
return self.prefix+'_'+bucket+'@'+key;
});
}else{
return self.prefix+'_'+bucket+'@'+keys;
}
}
现在我们要获取当前所有的角色,怎么获取了,这个主要给超级管理员。
我们只要拼接处 acl_meta@roles,就可以获得所有的角色了。
/**
allRoles( userId)
获得所有的Role
@param {String|Number}用户Id
**/
Acl.prototype.allRoles = function (userId) {
contract(arguments)
.params('string|number')
.end()
return userId ? this.userRoles(userId) :
this.backend.getAsync(this.options.buckets.meta, this.options.buckets.roles)
.then(roles => roles.filter(r => !!r))
}
到上面为止,我们分析数据结构之后,我们可以获取很多接口并没有暴露的数据了。
回到我们最关心的问题,这个不支持通匹配,怎么办???
- Mongodb-backend
项目主要考虑redis,这个不得己不会考虑 - 已有插件
查询了一遍,node_acl的插件倒是有几个,好像都是支持更多存储的 - 自定义扩展
- 目录权限继承
这个倒是可以考虑 - 手动维护,配合 acl.middleware的第一个参数,限定目录
这个很尴尬 - Acl.middleware + 额外开发中间件()
修改比较多,感觉不好 - await next()之后,再返回前重新拦截
很无赖的想法
这里就先有限考虑自定义扩展,先静静的看看API,思路如下:
- userId => roles (userRoles)
- roles => resources | 依据实际条件缓存 (whatResources)
- 通过resources来匹配path,查找到满足条件的resources|resource
- 通过匹配的resource查询访问权限 (isAllowed)
1,2,4都是有现成的API,唯独3要自己实现,这里就要提到 path-to-regexp, express和koa都是基于这个来显示路由匹配的,那么我就有了上面的想法。
/**
getMappedRerouces(path,resources)
获得用户有关联的所有资源
@param {String|Number}当前要匹配的路径
@param {Array}当前用户可以访问的所有Resource
*/
function getMappedRerouces(path, resources) {
return [].concat(resources.filter(r = dbRe => {
//TODO:: 第二个参数option调研
let re = pathToRegexp(dbRe)
return !!re.exec(path)
}))
}
这个就可以获取当前请求path匹配的所有Resource,
很可能是多条,那么怎么办,任何一条匹配就应该是可以。
那么我们上最后的代码
const Acl = require('acl')
const contract = require('../node_modules/_acl@0.4.11@acl/lib/contract')
const pathToRegexp = require('path-to-regexp')
const originalIsAllowed = Acl.prototype.isAllowed
/**
getMappedRerouces(path,resources)
获得用户有关联的所有资源
@param {String|Number}当前要匹配的路径
@param {Array}当前用户可以访问的所有Resource
*/
function getMappedRerouces(path, resources) {
return [].concat(resources.filter(r = dbRe => {
//TODO:: 第二个参数option调研
let re = pathToRegexp(dbRe)
return !!re.exec(path)
}))
}
/**
getAllResources( userId)
获得用户有关联的所有资源
@param {String|Number}用户Id
*/
Acl.prototype.allResources = function (userId) {
contract(arguments)
.params('string|number')
.end()
return userId ? this.userRoles(userId).then(roles => this.whatResources(roles)) : this._allResources()
}
Acl.prototype._allResources = function () {
return this.allRoles()
.then(roles => this.backend.unionAsync(this.options.buckets.resources, roles))
}
/**
allRoles( userId)
获得所有的Role
@param {String|Number}用户Id
*/
Acl.prototype.allRoles = function (userId) {
contract(arguments)
.params('string|number')
.end()
return userId ? this.userRoles(userId) :
this.backend.getAsync(this.options.buckets.meta, this.options.buckets.roles)
.then(roles => roles.filter(r => !!r))
}
/**
isAllowed( userId, resource, permissions, function(err, allowed) )
Checks if the given user is allowed to access the resource for the given
permissions (note: it must fulfill all the permissions).
@param {String|Number} User id.
@param {String|Array} resource(s) to ask permissions for.
@param {String|Array} asked permissions.
@param {Function} Callback called wish the result.
*/
Acl.prototype.isAllowed = function (userId, resource, permissions, cb) {
contract(arguments)
.params('string|number', 'string', 'string|array', 'function')
.params('string|number', 'string', 'string|array')
.end();
let args = [...arguments]
// 1.userId => roles
// 2.roles => resources | 依据实际条件缓存
// 3.通过resources来匹配path,查找到满足条件的resources|resource
// 4.通过匹配的resource查询访问权限
return this.allResources(userId)
.then(dbRe => getMappedRerouces(resource, Object.keys(dbRe)))
.then(resources => {
// 多个resource匹配的情况
return Promise.all((resources || []).map(re => {
return originalIsAllowed.apply(this, [args[0], re, ...args.slice(2)])
}))
}).then(allows => {
return allows.some(Boolean)
})
}
module.exports = Acl
怎么使用,
- 权限设置
- 中间件拦截
权限设置
acl.allow([
{
roles: 'user',
allows: [
{
resources: ['/msg', '/msg/:id', '/download', '/activities','/msg/(.*)'],
permissions: '*'
}
]
}
])
中间件拦截
const acl = require('../acl')
//const getAllRouter = require('./util/getAllRouter')
const pathToRegexp = require('path-to-regexp')
const loginPath = '/login'
module.exports = app => {
async function aclmd(req, res, next) {
var userId = 1024
if (userId) {
const path = req.path
if (path == loginPath) {
await next()
} else {
//const aa = await anyMatch(path, userId, acl)
const allowed = await acl.isAllowed(userId, path, '*')
if (allowed) {
next()
} else {
res.redirect(loginPath)
res.end();
}
}
} else {
res.redirect(loginPath)
res.end();
}
}
app.use(aclmd)
}