node_acl 权限管理路径通配

最近做一个基于nodejs的权限管理,查阅了一两天,发现大致是这样的:
passportjs
node-oauth
rbac
node_acl
express_acl
connect-roles

需求

  1. 按照模块,页面,API等级别做权限控制,暂时不需要做到按钮级别
  2. 主要程序开发完毕,需要侵入少
  3. 存储主要考虑redis
  4. 自己开发管理页面,方便自定义和维护

选取原则

  • 轻量级别
    passportjs太强大,大到怕怕
  • 文档清晰(示例|API)
    node_acl的readme 我只能说,真的是很友好,API也不多但是都非常get到点
  • 好上手
  • 容易扩展
  • 功能强大或者适用
  • 代码侵入少
    最讨厌到处修改代码
  • 人气指数相对比较高

最后选择了node_acl,主要是

  1. 人气相对比较高,大约2000star,
  2. 其次功能本身很独立,提供内存,mongo和redis三种存储方式
  3. API简单好用
  4. 文档好读
  5. 源码不多,方便去自定义和扩展
  6. 我们主要程序开发完毕,需要侵入少,研究后发现node_acl应该可以

确认node_acl后,就开始研究一些小细节和设计,说这么多,就是自己写一点代码进行接口功能测试。

问题列表:

  1. 权限继承
    addRoleParents满足需求,确实好用。比如guest, user ,admin三个Role, user集成guest, admin集成user。然后对不同的Role进行权限配置。还是不错的
  2. Resource 不支持同配
    这是什么意思,比如消息有下面几个路径, /msg/delete, msg/add, /msg/list,你就必须一条一条的配置,是不是很狗血
    Added the possibility to have a wildcard in resource name
  3. 不提供所有的Role查询的API
    我进入后,居然不知道有多少种Role
  4. 删除角色后,数据有残存
  5. 需要引入模块来关联页面或者API
  6. 初始化需要有超级管理员
  7. 默认设置,全部允许?全部不允许访问?
  8. 是否引入目录继承关系
  9. 。。。。。。

这里扒拉扒拉写这么多,很多只要API能做到,剩下的就是设计问题。麻烦的问题来了

  1. Resource不支持通配
  2. 不提供所有的Role查询的API

这两个底层基本的功能不支持,还玩个蛋。
冷静,冷静,我们打开源码,会发现,插件一共就7个js(版本0.4.11)

  1. acl.js
    核心之核心文件,暴露Role,Resource, Permission等等的API
  2. backend.js
    backend API定义,并没实际作用
  3. contract.js
    参数验证js
  4. memory-backend.js
    内存中存储
  5. mongodb-backend.js
    mongodb存储
  6. redis-backend.js
    redis存储
  7. index.js
    默认文件

三种backend都是存储数据的,那我们先导出数据来看一看:
关于怎么导出redis

  1. 安装redis-dump
  2. 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"
        ]
    }
}
  1. acl是前缀,在初始化acl的时候可以设置

    var acl = require('acl');
    // Using redis backend
    acl = new acl(new acl.redisBackend(redisClient, 'acl'));
  2. 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))
}

到上面为止,我们分析数据结构之后,我们可以获取很多接口并没有暴露的数据了。
回到我们最关心的问题,这个不支持通匹配,怎么办???

  1. Mongodb-backend
    项目主要考虑redis,这个不得己不会考虑
  2. 已有插件
    查询了一遍,node_acl的插件倒是有几个,好像都是支持更多存储的
  3. 自定义扩展
  4. 目录权限继承
    这个倒是可以考虑
  5. 手动维护,配合 acl.middleware的第一个参数,限定目录
    这个很尴尬
  6. Acl.middleware + 额外开发中间件()
    修改比较多,感觉不好
  7. await next()之后,再返回前重新拦截
    很无赖的想法

这里就先有限考虑自定义扩展,先静静的看看API,思路如下:

  1. userId => roles (userRoles)
  2. roles => resources | 依据实际条件缓存 (whatResources)
  3. 通过resources来匹配path,查找到满足条件的resources|resource
  4. 通过匹配的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

怎么使用,

  1. 权限设置
  2. 中间件拦截

权限设置

    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)
}

node acl demo
node权限控制模块node_acl的应用

上一篇:Redis之布隆过滤器


下一篇:我的音乐盒子(nodejs7 + koa2 + vue + vuex + vue-router)