Authorization


账户登录和管理

由于数据库操作对weroll应用不是必须的,因此weroll没有集成用户账户管理功能,需要开发者根据自己需要实现维护账户数据,登录验证,和密码修改等功能。

Session

weroll内置了Session管理功能,使用 weroll/model/Session 对象可以对用户的登录会话进行管理和校验。
weroll的Session采用的是令牌校验的机制,即当用户登录成功之后,weroll生成一个16位的随机字符串作为令牌(以下我们称为token),并将token传递给客户端,客户端在随后每一次API请求或页面请求都会附带这个token,weroll会对它做验证,以维护用户会话的状态。
对于WebApp来说,会话Token将存放在客户端的cookie中。
对于APIServer来说,Token需要显式的返回给客户端(例如通过一个login的API响应Token给客户端),由客户端决定以何种方式存储它。 随后的每次API请求,客户端都需要将Token连同请求数据一起发送给服务器。


启用Session:

/* ./server/config/%ENV%/setting.js */
module.exports ={
    ...
    session: {
            /* user access session config. enable redis first */
            onePointEnter:true,    //whether allow create session in multi client device
            cookiePath:"/",      //cookie path for client browser
            cacheExpireTime:3 * 60,     //session cache expire time, sec
            tokenExpireTime:24 * 60 * 60,  //session token expire time, sec
            cookieExpireTime:24 * 60 * 60 * 1000  //million sec
    },
    ...
}


其中有3个重要的参数设置:

Setting Description
session.onePointEnter 作用是是否允许同一个用户在多个客户端创建会话,如果为true则表示不允许,最近一次用户创建会话会覆盖之前创建的会话信息,使其他客户端失去访问权限。
session.cacheExpireTime 表示会话数据的缓存过期时间。在weroll中,会话数据会存储到redis中,防止node进程销毁导致用户会话丢失;同时再缓存到node进程内存中,以提高Token校验的性能,cacheExpireTime 参数即表示在内存中的过期时间,内存数据过期后,weroll会从redis中读取会话数据,并再次写进内存中。
session.tokenExpireTime 表示会话的过期时间。

使用Session必须要配置Redis连接,请参考 Guide - Redis

创建登录会话

假设你已经实现了用户登录验证:

//得到了用户的id(或者_id,对于MongoDB来说)
//user --> { _id:"1001", nickname:"Jay", type:100 }
var Session = require("weroll/model/Session");

//callback
Session.getSharedInstance().save(user, function(err, sess) {
    if (err) return console.error(err);
    console.log(`session saved --> token: ${sess.token}     tokentimestamp: ${sess.tokentimestamp}`);
});

//Promise
Session.getSharedInstance().save(user).then(function(sess) {
    //session saved
}).catch(function(err) {
    //save error
});

//async & await
async function() {
    var sess = await Session.getSharedInstance().save(user);
    console.log(`session saved --> token: ${sess.token}     tokentimestamp: ${sess.tokentimestamp}`);
}

会话创建之后,开发者需要将 tokentokentimestamp 交给客户端。如果你使用WebApp开发网页项目,可以将令牌数据写到客户端请求的cookie里,例如:

//after user login
//user --> { _id:"1001", nickname:"Jay", type:100 }

var sess = await Session.getSharedInstance().save(user);

var option = {
    //设置cookie的path参数
    path: Setting.session.cookiePath || "/",
    //设置cookie的过期时间
    expires: new Date(Date.now() + Setting.session.cookieExpireTime)
};
res.cookie("userid", sess.userid, option);
res.cookie("token", sess.token, option);
res.cookie("tokentimestamp", sess.tokentimestamp, option);
//end this response

如果你使用APIServer,可以将令牌数据通过API响应的方式,返回给客户端,例如:

/* ./server/service/UserService.js */

//define "user.login" API
exports.login = async function(req, res, params) {
    //check account and password ...
    //if existed, then we get an user data
    //user --> { _id:"1001", nickname:"Jay", type:100 }
    //now we create session
    var sess = await Session.getSharedInstance().save(user);
    //response token and other data to client
    res.sayOK(sess);
}


Session验证

客户端获得token数据后,在随后的API请求或页面访问等操作中,需要把userid, tokentokentimestamp这3个数据提交给服务器进行验证。weroll并不关心客户端如何存储和管理token数据,你可以存放在cookie里,或者LocalStorage里,或者是移动设备的本地文件里。

对于使用WebApp来说,如果创建会话后将token等数据写到了客户端cookie里,那么客户端并不需要做什么特别的处理,浏览器会自动在每次请求时附带cookie数据。weroll会自动从请求的cookie中获得token并进行校验。

如果你使用APIServer,可以将token等数据连同API请求参数一起提交给服务器进行校验,示例代码如下:

/* client side */

var params = {};
//set api name
params.method = "user.changeHead";
//set api request data
params.data = { "head":"123.jpg" };
//submit token data
params.auth = {
    userid:"YOUR_USER_ID",
    token:"YOUR_TOKEN",
    tokentimestamp:"YOUR_TOKEN_TIMESTAMP"
};

$.ajax({
    type: "post",
    url: "http://localhost:3000/api",
    headers: {
        "Content-Type": "application/json; charset=UTF-8"
    },
    data: JSON.stringify(params),
    success: function (data, status, xhr) {
        if (data.code == 1) {
            console.log('API ok: ', data);
        } else {
            console.error('API error: [' + data.code + '] - ' + data.msg);
        }
    }
});



API和View Router的权限控制

当你使用了 weroll/model/Session 管理用户会话之后,则可以给每一个API和View Router设定访问权限,例如:

/* ./server/service/UserService.js */
exports.config = {
    name: "user",
    enabled: true,
    security: {
        //将needLogin参数设置为true,则表示该接口需要Session校验通过才能访问
        //否则API将返回 { code:100, msg:"NO_PERMISSION" }
        "hello":{ needLogin:true, checkParams:{ name:"string" }, optionalParams:{ gender:"int" } }
    }
};

exports.hello = function(req, res, params, user) {
    //user 对象则是 Session.save 时传递的数据
    console.log("user id: ", user.id);  //or user.userid
    console.log("user token: ", user.token);
    //some codes ...
}


/* ./server/router/page.js */
function renderSomePage(req, res, output, user) {
    //user 对象则是 Session.save 时传递的数据
    console.log("user id: ", user.id);  //or user.userid
    //output({ ... });
}

exports.getRouterMap = function() {
    return [
        //将needLogin参数设置为true,则表示该页面需要Session校验通过才能访问,否则将自动跳到login页面
        { url: "/some_page", view: "some_page", handle: renderSomePage, needLogin:true }
    ];
}



进阶技巧

缓存更多的用户数据

假设API或View Router的业务逻辑,经常需要使用用户的某些数据,而又不会经常发生变化的,例如昵称,性别,头像等。可以利用创建会话 Session.save() 将这些数据和token数据缓存在一起,这样可以大量减少数据库查询和相关代码。实例如下:

/* Session.save */
//query from Database: userData --> { _id:"1001", nickname:"Jay", head:"123.jpg", arg1:{...}, type:100 }
var Session = require("weroll/model/Session");

var user = { userid:userData._id, type:userData.type };
user.extra = [ userData.nickname, userData.head, userData.arg1 ];

//callback
Session.getSharedInstance().save(user);

/////////////////////////////////////////////////////////////

/* ./server/router/page.js */
function renderSomePage(req, res, output, user) {
    console.log("user id: ", user.id);  //or user.userid
    //use extra to get more properties of user
    console.log("user nickname: ", user.extra[0]);
    console.log("user head: ", user.extra[1]);
    console.log("user arg1: ", user.extra[2]);
    //output({ ... });
}



高级访问控制

在某些业务场景下,仅仅依据用户是否登录来决定访问权限是不足以满足业务需求的,例如VIP用户才可以访问某些页面,或者只有高级管理员才能执行某些删除和修改操作,因此我们需要根据用户的类型做更精细的访问控制。
在weroll应用中,开发者可以在API和View Router配置中使用allow参数,定义更精细的权限控制。实例如下:

/* ./server/service/UserService.js */
exports.config = {
    name: "user",
    enabled: true,
    security: {
        //将needLogin参数设置为true,则表示该接口需要Session校验通过才能访问
        //设置allow参数,表示只有user.type = 1或2 的用户才能使用此接口
        //否则API将返回 { code:100, msg:"NO_PERMISSION" }
        "hello":{ needLogin:true, allow:[ [ "type",[1,2] ] ] }
    }
};

当客户端请求使用 user.hello 接口时,weroll会先检查Session,然后再检查allow参数(如果存在的话),当两者都通过时才进入业务逻辑代码。
allow 参数是数组结构,因此允许开发者定义多个检查项,weroll会按照顺序逐一执行,一旦检查失败则中断退出,例如:

allow:[ [ "type",[1,2] ], [ "check-1",[ array args... ] ], [ "check-2",{ hash args... } ] ]

type 是weroll自带的一个过滤器,weroll允许开发者自定义过滤器。假设这样的业务场景:某些页面只允许VIP等级大于等于3级的会员用户访问,我们可以这样做:
配置View Router的allow参数:

/* ./server/router/vip_page.js */
function renderVIP_Page(req, res, output, user) {
    //vip user can access
    //output({ ... });
}

function renderVIP_3_Page(req, res, output, user) {
    //vip user (level >= 3) can access
    //output({ ... });
}

exports.getRouterMap = function() {
    return [
        { url: "/vip_page", view: "vip_page", handle: renderVIP_Page, needLogin:true,
          //use 'custom' checker, vip level must >= 1
          allow:[ [ "custom",{ vipLevel:" >= 1" } ] ]
        },
        { url: "/vip_3_page", view: "vip_3_page", handle: renderVIP_3_Page, needLogin:true,
          //use 'custom' checker, vip level must >= 3
          allow:[ [ "custom",{ vipLevel:" >= 3" } ] ]
        }
    ];
}

使用 AuthorityChecker 对象注册自定义检查器:

/* somewhere */
var AuthorityChecker = require("weroll/utils/AuthorityChecker");

//define check function
var vipLevelCheck = function(user, allow, callBack) {
    //allow --> { vipLevel:"..." }

    /* Async check:
       //you can query vipLevel data of user from Database or somewhere
        User.findOne({ _id:user.id }, { vipLevel:1 }, function(err, doc) {
            user.vipLevel = doc.get("vipLevel");
            var result = eval(user.vipLevel + allow.vipLevel);
            callBack(result);
        });
    */

    /* Sync check */
    //eval("user.vipLevel >= N")
    var result = eval(user.vipLevel + allow.vipLevel);
    //must execute callBack(true or false) to end this check
    callBack(result);
}

//register as name "custom"
AuthorityChecker.register("custom", vipLevelCheck);

检查器中的user参数时 Session.getSharedInstance().save(user) 时的用户数据,你可以把经常需要用来做权限检查的数据,在save是存放在 user.extra 对象中,以减少访问数据库的次数。

现在当用户访问 vip_page 和 vip_3_page 这2个页面时,就会对用户的vipLevel值进行检查。检查失败和登录检查失败一样,请求将被重定向到login页面;如果是API,检查失败则会返回 NO_PERMISSION 错误。



自定义Session检查

weroll允许开发者完全定义Session检查,只需要重写 WebAppAPIServerhandleUserSession() 方法。
例如,我们改用MongoDB数据库来读写会话数据,示例代码如下:

//user login successfully
//user --> { _id:"1001", nickname:"Jay", type:100 }
var Model = require("weroll/model/Model");
var Utils = require("weroll/utils/Utils");

var token = Utils.randomString(16);
var now = Date.now();
//upsert a session data into "__session" table of MongoDB
Model.DB.update("__session", { _id:user._id, token:token, tokentimestamp:now }, { upsert:true });


在 ./server 目录下新建 WebAppExt.js 用来扩展 WebApp 或 APIServer:

/* ./server/WebAppExt.js */
var Session = require("weroll/model/Session");

exports.extend = function(webApp) {
    //override "handleUserSession" method
    webApp.handleUserSession = function(req, res, next, error, auth) {
        var user = { isLogined:false };
        //find session data from MongoDB
        Model.DB.findOne("__session", { _id:auth.userid, token:auth.token }, function(err, doc) {
            if (err) return error(err, user);
            if (doc) {
                //session passed
                next(1, user);
            } else {
                //no session
                next(0, user);
            }
        });
    };
}
/* ./main.js */
//create and start a web application
var webApp = require("weroll/web/WebApp").start(Setting, function(webApp) {
    //do something after HTTP service initialized.
    cb();
});
//extend WebApp
require("./server/WebAppExt").extend(webApp);