把会话放在Node层 我们这样做的很开心😋

5,010 阅读11分钟

简介

每次有新项目启动,你会不会和我一样有这样的忧愁,后端这次用seisson还是token呀?如果是token的话有没有续签的问题呀?又是重复的联调,满脑子的登录、退出,哎 😔...

现在的流程:

  1. 后端发邮件申请开通SSO和RBAC,提供系统标识
  2. 前端填入项目信息,自动完成了SSO和RBAC的接入。
  3. 生成RSA秘钥,后端业务系统下载解签模板,RSA联调工具联调

你发现没有,前后端工作被简化到产品都能自己干了😋。

痛点真的好痛呀

公司有很成熟的SSO和RBAC系统,已经很方便了,但随着项目的增加,并没有让我感到愉悦,每次有新项目启动,就会带来重复的工作:

1. 会话功能 用户登录SSO后,只会返回一个token,后端需要根据token获取用户信息和权限,并在自己的系统中建立会话,带来一系列前后端登录、退出功能的开发和联调。

2. 权限校验 RBAC提供完整的功能,接口权限需要录入Controller、Action,后端需要根据RBAC的信息进行权限校验,单多个业务系统间并不能复用,这个功能代码会出现在每个业务系统里。

有没有解决方案 任由其发生、听之任之还是另辟蹊径,引用《大教堂与集市》中的一句话:

要当一名黑客,你必须得非常相信这点,并希望尽可能将那些无趣的事情自动化,这不仅是为自己,也是为其他人。

前端组与多个业务开发组密切合作,应该天然的肩负起这样的职责,为自己也为他人。

我们的思路是建立一个Node中间层,使用JWT做无状态登录,所有前端接口通过Node转发到业务系统附带用户信息,转发前做权限校验,完美解决,原来实现真的这么简单吗?

实现思路

浏览器端 我去哪儿

第一步就是浏览器把接口数据发送给Node,那么浏览器与Node通讯如何区分是哪个系统,Node转发前如何确定转发到系统的哪个环境(测试、生产)?多域名处理?如果业务系统不希望使用转发怎么办?

浏览器端的职责就清晰了,就是要告诉Node端,我去哪儿,去哪个系统下的哪个环境,并且附带了哪些数据。

信息录入

我们大部分的项目只有测试、生产环境,所以预留了测试、线上地址,并且可增加多域名,录入到系统内,点击保存,会在前端git目录中生成一个配置文件供后续的请求使用。

前端所有请求的接口地址生成在组件内,前半部分为后端服务的ID,后半部分为接口地址。

注:我们的前端页面使用内部系统配置生成,思路可借鉴。

请求发送

这是一个典型的代理模式,所有请求代理到request方法中,这个模块负责区分转发与非转发的接口,根据fetchUrl、前端环境变量匹配到后端地址,并且带上系统标识发送给Node服务端。

第一类是直接与Node交互的接口,比如登录、用户详情等。
第二类是要在Node端带上用户信息和接口数据转发给业务端。

这两类接口都会带上systemNameNode,具体来开下需要转发的的接口格式。
接口格式如下:

参数 说明 类型
agentPath 转发地址 String
url 环境变量
method 接口请求范式 String
time 时间戳 String
random 随机数 String
data 序列化的接口数据 String
sign md5数据签名 String

举个例子:

agentPath就是最终Node要发送给业务端的接口地址,systemNameNode是系统标识,用来匹配私钥,data就是具体业务接口要用的数据,sign只是把其他字段做了个简单的md5在Node端验证一下。

剩下的就交给Node思考一个哲学问题了,我是谁,哈哈 就是咱们的会话部分了。

Node会话实现 我是谁

接口发送的,Node拿到了转发地址,接口数据,直接发送不就完了吗?
哈哈 当然不是了,我们还要接入SSO,再通过SSO给到的用户信息,建立Node层的会话,这一节基本上就是Node和JWT的东西了,开始我们的第一步吧。

跨域

前端我们使用的是AntPro,请求工具是umi-request,需要注意的是,如果发送get请求,无意间把值给到了body上,千万记得删除,不然不报错不发请求🙃。

const jsonMethod = ['POST','PUT','DELETE'];
if (jsonMethod.includes(newOptions.method)){
    //...
} else {
    // 序列化参数
    if (newOptions.body) url += `?${stringify(newOptions.body)}
    // 删除body
    delete newOptions.body
}

Node端设置允许跨域,跨域区分简单请求与复杂请求(带预检测的请求)跨域介绍,由于我们在HTTP头使用了自定义属性'token',属于复杂请求,会多发送一次预检测请求。

app.all('*', function (req, res, next) {
  res.header("Access-Control-Allow-Origin",     req.headers.origin);
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, token');
  res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
  res.header("Access-Control-Allow-Credentials", "true");
  res.header("Content-Type", "application/json;charset=utf-8");
  if (req.method == 'OPTIONS') {
    res.send(200); /*让options请求快速返回*/
  }
  else {
    next();
  }
});

两次请求


express-jwt的辅助设置

我们使用jsonwebtokenexpress-jwt这两个工具,jsonwebtoken很成熟就不多说,express-jwt可以很方便的让expressjwt结合使用。

环境变量
我们使用字符串和环境变来定义secret,能够避免token无法区分开发环境的问题。

指定token源
getToken指定token源,之前有使用cookie,这样能够避免前端做一些存取token的工作,比如退出登录只需要Node端在删除cookie就可以了;但是老大说考虑到未来其他客户端的扩展,如小程序、手机端,我们预留了query.token和headers.token。

非登录接口
使用unless指定不走token验证的接口,如果使用正则就放在数组的最后一位,否则正则不通过就直接返回false了🙂(表示栽过🙋)。

//签名加盐
const secret = 'salt' + process.env.ACTIVE;

//使用中间件验证token合法性
app.use(expressJwt({
  secret: secret,
  getToken: function fromHeaderOrQuerystring(req) {
    if (req.query && req.query.token) { // 使用query.token
      return req.query.token;
    } else if (req.headers.token) {  // 使用req.headers.token
      return req.headers.token
    }
    return null;
  }
}).unless({
  path: ['/login','/agent/rsatool', /^\/proxy\/.*/,]  //除了这些地址,其他的URL都需要验证
}));

错误拦截
token验证不通过,直接返回{ status: 41002 },我更期望使用http状态码,但是提议没有被通过🙄,就没删除res.status的代码,后续再争取下🤤。

//拦截器
app.use(function (err, req, res, next) {
  //当token验证失败时会抛出如下错误
  if (err.name === 'UnauthorizedError') {
    //这个需要根据自己的业务逻辑来处理( 具体的err值 请看下面)
    res.status(200).send({ code: -1, msg: '未登录', status: 41002 });
  }
});

解析
所有通过token验证的接口,我们把解析后的用户信息放在req.tokenDecode上,方便接口使用。

// 解析token
app.use((req, res, next) => {
  // 获取token
  let token = req.headers.token;

  if (token) {
    let decoded = jwt.decode(token, secret);
    req.tokenDecode = decoded
  }
  next()
});

登录流程

以上只是是辅助,不生成token白搭😜,接下来我们一块看看登录的环节,如何根据SSO信息生成token。

判断登录

  1. 浏览器发送获取用户详情请求
  2. Node解析token,为空或token失效返回未登录
  3. 浏览器跳转SSO登录页面
  4. 登录成功 跳转至 前端域名+SSOtoken
  5. 浏览器发送SSOtoken请求到Node登录
  6. Node将SSOtoken发至SSO获取用户信息
  7. Node使用用户信息生成token,并将token和用户信息发送给前端。

不要混淆SSOtokentokenSSOtoken是用来发送至SSO获取用户信息;而token是通过用户信息生成出来的。

简单点说:用户必须是SSO系统的用户,才能生成SSOtoken,然后获得用户信息生成token,不再赘述🤔。

生成token
使用jwt.sign生成token,包含用户名、权限信息。

app.use('/login', async function (req, res) {

  // 系统标识
  let systemName = req.query.systemNameNode;

  // 无系统标识 直接退出
  if(!systemName) {
    res.send({ code: -1, msg: '无系统标识' });
    return
  }
  // 获取用户信息
  let userInfo = await fetch(baseConfig.SSO + "/api/sso/verifyToken", {
    method: "post",
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: encode({ token: req.query.token, system: systemName })
  }).then((res) => res.json())

  if (userInfo.code !== -1) {

    let { masterName, masterFullName } = userInfo.data
    // 根据用户信息获取菜单
    const menu = await getRBAC({ masterName, system:systemName})

    // 403
    if(menu.code === -1 ) {
      res.status(403).send(menu);
    } else {
      // 接口权限
      const { powers } = menu.data;
      //生成token
      const token = jwt.sign({
        systemName,
        masterName,
        masterFullName,
        powers,
      }, secret, {
        expiresIn: 3600 * 2 //到期时间
      });
      res.send({ token: token, ...userInfo.data });
    }
  } else {
    res.send(userInfo)
  }

});

退出只是前端自己将存在浏览器中的token删除,并没有考虑提前注销和token续签。

知道了用户是谁,接下来就是转发了,Node层要根据权限判断是否转发给业务端,那么怎么与业务端呢通讯?有没有安全问题?😋一起来看看转发的部分吧。

转发实现部分

最终目的是要解决2个重复劳动的问题,第一个已经解决了,第二个就简单很多了,只需要在转发前根据用户权限判断一下即可,Node与业务端通讯使用RSA做了简单的加签RSA的应用太广泛了见阮一峰 RSA算法原理(一)

权限判断

RBAC中需要录入ControllerAction信息,对应的就是接口地址,
如果下图,对应接口地址即为web/delinter

从RBAC拿到的权限信息如下图,这部分信息存在token里,只需要在转发前,从    req.tokenDecode中验证下结果即可。

为了方便阅读,再把接口格式贴下吧。

// rbac接口权限判断
	const { url } = req.body
	// 环境变量、域名、接口地址
	let [ env, host, apiUrl ] = url.split('||');
	// host 去掉域名http和环境变量信息
	host = hostWipeEnv(host);
	// 获取控制器和行为
	let [ , Controller, Action ] = apiUrl.split('/')
	// key转小写,rabc中数据key为小写
	Controller = Controller.toLowerCase();
	Action = Action.toLowerCase();
	// 不带域名 带斜杠与不带斜杠的权限
	const noHostPowers = _.get(powers, `/${Controller}.${Action}`) || _.get(powers, `${Controller}.${Action}`);
	// 带域名
	const key = `${host}:/${Controller}`;
	const hasHostPowers = _.get(powers[key], `${Action}`)

	// 权限判断
	if (!noHostPowers && !hasHostPowers ){
		res.send({ code: -1, msg: '接口无权限' });
		return
	}

接口格式

通讯就要有既定规则,业务系统需要知道用户信息和接口数据,其他只是为了增加破解复杂度。

加签

RSA加签
根据systemNameNode获取私钥文件,使用私钥加签。

1. 生成参数对象
包含 username、system、data,以及生成的time、random属性。

代码实现:

'use strict';

// 生成签名
const Encrypt = require('./encrypt');

/**
 * 处理接口请求的参数,添加全局参数
 * username: 当前登录的用户名
 * system: 当前使用的系统名
 * time: 当前时间戳
 * random: 随机数
 * data: 实际请求的参数
 */
module.exports = function (data, signStr, masterName, systemName) {

  const newParam = {
    username: masterName,
    system: systemName,
    time: Date.now(),
    random: Math.random(),
    data,
  };

  // 生成签名
  const sign = new Encrypt(newParam, signStr).value;
  newParam.sign = sign;
  return newParam;
};

2. 序列化参数对象
将第一步生成的参数使用sortAndQuery函数序列化,序列化后的格式如下

data={"page":"1","limit":"10"}&random=0.10105494318877817&system=op-log&time=1578997818828&username=qinshaowei

代码实现

'use strict';

/**
 * 把Object的属性名从小到大排序,如果属性的值是对象则转成JSON字符串,并把所有属性转成query字符串
 * obj: 要转换的对象
 */
module.exports = function (obj) {

  const keys = Object.keys(obj).sort();
  const query = keys.map(key => {
    let value = obj[key];
    if (typeof value === 'object') {
      value = JSON.stringify(value);
    }
    return `${key}=${value}`;
  }).join('&');
  return query;
};

3. 使用序列化字符串生成签名
将上一步的字符串使用RAS算法生成签名,并且返回入参对象。

'use strict';
/**
 * 用密钥对文本做签名
 */

// 转换对象为query字符串
const sortAndQuery = require('./sortAndQuery');

const NodeRSA = require('node-rsa');

// 生成RSA密钥对象
let private_key;

class Encrypt {
  constructor(params, signStr) {
    // 秘钥赋值
    private_key = new NodeRSA(signStr)
    if (params) {
      // 如果new对象时就有传入参数,则调用签名方法,返回签名结果,因为class构造函数只能返回对象,所以...
      const signStr = this.sign(params);
      return {
        toString: () => signStr,
        value: signStr,
      };
    }
  }

  // 对参数对象做签名,返回生成的签名
  sign(params, signStr) {
    // 先把对象转成query字符串,再做签名
    const queryStr = sortAndQuery(params);
    const sign = private_key.sign(queryStr, 'base64', 'utf8');
    return sign;
  }

}

module.exports = Encrypt;

签名验证

Node版

// 序列化方法
const sortAndQuery = Param => {
  // 排序
  const keys = Object.keys(obj).sort();
  // 对象转字符串
	const query = keys.map(key => {
    let value = obj[key];
    if (typeof value === 'object') {
      value = JSON.stringify(value);
    }
    return `${key}=${value}`;
  }).join('&');	
	return query;  
};


// 初始化
const init = req => {
  const { sign, ...params } = req.body;
  // 公钥字符串
	const public_key_data = '-----BEGIN PUBLIC KEY-----\n' +
  'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCD4EalJIz4YMGrj6oARl30Rji7\n' +
  'cH9mzW2p2sNIUmNb48TeR7WN17z1lPxPuFkODmMyRuU7i+zZNbx2OZ/lAx8gGNQN\n' +
  'BdeUWBUAJX0KfLc3y6bY/k81PE1V7SOV3Ordd8aJwQO/bQnNcDUvC6WP3feTnbu7\n' +
  'Mja0HHTpXYcVn9uO+wIDAQAB\n' +
  '-----END PUBLIC KEY-----';
  const public_key = new NodeRSA(public_key_data);
	const dataStr = sortAndQuery(params);
  // 返回签名验证是否成功,true or false
	return public_key.verify(dataStr, sign, 'utf8', 'base64');
}

PHP联调

在与php业务系统联调的时,签名验证不通过,使用同样的私钥、数据生成的签名信息不一致。

原因是node-rsa和php的openssl默认签名规则不一样,PHP指定SHA256即可,如图证:


GO联调

与go系统联调时,生成的签名在控制台打印完全一样,go自己的签名可以通过,Node给的不同通过。

打印NodeRSA生成签名时发现,如第二个入参不传,默认输出的是buffer类型,而go生成的签名是bety格式,需要将Node生的base64转化为bety格式。

重点 真的完美了吗?

好了,终于把前面提到的两个问题干掉了,开心🙂,等等,真的就完美了吗?
再引用一句《大教堂与集市》里的话

在你第一次把问题解决的时候,你往往并不了解这个问题,第二次你才可能知道怎么把事情做好。所以,如果你想做对事情,至少要再做一次。

我们发现并且用自己的方式解决了问题,随后才知道衍生了新的问题。

在联调RSA时,业务端希望能够总提供一个固定值生成的签名信息用来联调,不通过时怀疑公私钥有问题,要求更换公钥私钥。

联调确实占用了我很多时间,并且整个过程都在彼此怀疑与自我怀疑😂,那么 再做一次干掉新增的重复劳动

干掉新增的重复劳动

新增加的重复劳动可能并不多,但未来更多的项目接入,花在联调上的时间很有可能指数增长。

解决方案

  1. 自动生成公、私钥,将私钥同步到Node应用中
  2. 可下载
  3. 提供调试接口。

生成秘钥

这个比较简单,直接用RSA生成即可

const key = new NodeRSA({ b: 512 }); // 生成512位的密钥
const publicDer = key.exportKey('pkcs1-public-pem'); // 公钥
const privateDer = key.exportKey('pkcs1-private-pem'); // 私钥

推送到git

所有项目使用gitlab托管,标配运维大佬们使用githook+docker的自动部署,使用gitlab的api更新代码,自动部署,优秀😘。


// 公钥私钥路径
const publicPath = `base/RSA/${proName}/pkcs1-public.pem`;
const privatePath = `base/RSA/${proName}/pkcs1-private.pem`;

// 获取文件返回详情 用于判断更新文件的更新方式
const publicInfo = await getGitLabInfo(publicPath);
const privateInfo = await getGitLabInfo(privatePath);

const getGitLabInfo = async path => {
    const result = await getFlieContent(2660, path, 'develop').then(res => res).catch(err => false);
    return result;
  };

// 生成公钥
await setFlieContent({
      projectID: 2660,
      filePath: publicPath,
      branch: 'develop',
      content: publicDer,
      method: getMethod(publicInfo),
      message: `${userName} 生成公私钥`,
    });

// 生成私钥
await setFlieContent({
  projectID: 2660,
  filePath: privatePath,
  branch: 'develop',
  content: privateDer,
  method: getMethod(privateInfo),
  message: `${userName} 生成公私钥`,
});

gitlab的api
同事伙伴封装了setFlieContent方法,直接调用。

/**
 * 修改文件内容
 * @param { 项目ID } projectID
 * @param { 文件路径 } filePath
 * @param { 分支名 } branch
 * @param { 文件内容 } content
 * @param { commit信息 } message
 * @param { 用户email } email
 * @param { 用户名 } username
 * @param { 请求类型 } method
 */
function setFlieContent({
  projectID,  filePath,  branch,  content,  message,  email,  username,  method,
}) {
  const pathFile = encodeURIComponent(filePath);
  return new Promise((resolve, reject) => {
    request({
      url: `https://git.xin.com/api/v4/projects/${projectID}/repository/files/${pathFile}`,
      method,
      headers: {
        'content-type': 'application/json',
        'PRIVATE-TOKEN': PersonalAccessTokens,
      },
      body: JSON.stringify({
        branch,
        content,
        commit_message: message || '接口提交',
        author_email: email || 'machine_xin_com@xin.com',
        author_name: username || 'machine_xin_com',
      }),
    }, (error, response, body) => {
      if (!error && (response.statusCode === 200 || response.statusCode === 201)) {
        const data = JSON.parse(body);
        resolve(data);
      } else {
        reject(error);
      }
    });
  });
}

有疑惑吗?getMethod是干嘛的?为什么返回状态码有200或者201的判断?

我也是后来才知道,被顶级项目感动,因为gitlab严格按照HTTP规范接口,methodPUTPOST分别为更新、新增文件,成功状态码分别为200、201。

下载秘钥

通过读取gitlab文件内容转为buffer,调用expresssend下载文件即可。


const { getFlieContent } = require('../git/fileLIst');

const getRSA = async (req, res) => {

  const { proName, keyType } = req.query;
  const keyTypes = ['public', 'private'];
  // 类型判断
  if (!keyTypes.includes(keyType)) {
    res.send({code: -1,msg: '类型错误'})
    return
  }

  try {
    // 获取秘钥内容
    const keyString = await getFlieContent(2660, `base/RSA/${proName}/pkcs1-${keyType}.pem`, 'develop');
    // 转为buffer
    const buff = Buffer.from(JSON.stringify(keyString, '\t', 2));
    // 设置文件类型
    res.set({'Content-Disposition': `attachment; filename=pkcs1-${keyType}.pem`});
    // 发送文件
    res.end(buff);
        
  } catch (error) {
    res.send({code: -1,msg: error})
  }

};

module.exports = getRSA;

调换工具

工具就简单很多了,只是提供一个便捷的方式,预览图。

有趣与成长

仅仅在联调RAS的过程中就收获了很多知识点,比如Mac自带apacheVScode可以直接运行go或php的代码,跨域区分简单、复杂请求等等😇。

我认为这是一个有趣的过程,通过技术解决重复低端劳动,更有意思的是,在这个过程中你可以收获价值与成长;最近的工作让我意识到自己的不足,学到很多,确发现没学到的更多,开始补习设计模式、软件复杂度、设计原则,听John Ousterhout、 Erich Gamma的演讲,开始着手学源码架构等。

今天是农历2019年的最后一天班,这一年,我可以用google,写Node、React、vue,开发页面生成工具,做自己感兴趣的工作,我能在工作中意识到自己的不足和价值,越发明确自己的方向,这使我对目前的工作感到幸福,我想我可以给我的2019画上一个圆满的句号了。

最后,愿春节后瘟疫散去,愿未来的日子里,你能够在工作中感到幸福。