简介
每次有新项目启动,你会不会和我一样有这样的忧愁,后端这次用seisson还是token呀?如果是token的话有没有续签的问题呀?又是重复的联调,满脑子的登录、退出,哎 😔...
现在的流程:
- 后端发邮件申请开通SSO和RBAC,提供系统标识
- 前端填入项目信息,自动完成了SSO和RBAC的接入。
- 生成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的辅助设置
我们使用jsonwebtoken
和express-jwt
这两个工具,jsonwebtoken
很成熟就不多说,express-jwt
可以很方便的让express
和jwt
结合使用。
环境变量
我们使用字符串和环境变来定义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。
判断登录
- 浏览器发送获取用户详情请求
- Node解析
token
,为空或token
失效返回未登录 - 浏览器跳转SSO登录页面
- 登录成功 跳转至 前端域名+
SSOtoken
- 浏览器发送
SSOtoken
请求到Node登录 - Node将
SSOtoken
发至SSO获取用户信息 - Node使用用户信息生成
token
,并将token
和用户信息发送给前端。
不要混淆SSOtoken
和token
,SSOtoken
是用来发送至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中需要录入Controller
、Action
信息,对应的就是接口地址,
如果下图,对应接口地址即为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时,业务端希望能够总提供一个固定值生成的签名信息用来联调,不通过时怀疑公私钥有问题,要求更换公钥私钥。
联调确实占用了我很多时间,并且整个过程都在彼此怀疑与自我怀疑😂,那么 再做一次干掉新增的重复劳动。
干掉新增的重复劳动
新增加的重复劳动可能并不多,但未来更多的项目接入,花在联调上的时间很有可能指数增长。
解决方案
- 自动生成公、私钥,将私钥同步到Node应用中
- 可下载
- 提供调试接口。
生成秘钥
这个比较简单,直接用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规范接口,method
的PUT
和POST
分别为更新、新增文件,成功状态码分别为200、201。
下载秘钥
通过读取gitlab
文件内容转为buffer
,调用express
的send
下载文件即可。
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自带apache
,VScode
可以直接运行go或php的代码,跨域区分简单、复杂请求等等😇。
我认为这是一个有趣的过程,通过技术解决重复低端劳动,更有意思的是,在这个过程中你可以收获价值与成长;最近的工作让我意识到自己的不足,学到很多,确发现没学到的更多,开始补习设计模式、软件复杂度、设计原则,听John Ousterhout、 Erich Gamma的演讲,开始着手学源码架构等。
今天是农历2019年的最后一天班,这一年,我可以用google,写Node、React、vue,开发页面生成工具,做自己感兴趣的工作,我能在工作中意识到自己的不足和价值,越发明确自己的方向,这使我对目前的工作感到幸福,我想我可以给我的2019画上一个圆满的句号了。
最后,愿春节后瘟疫散去,愿未来的日子里,你能够在工作中感到幸福。