在上一篇笔记中,提到使用Node.js做了中间层,对接SSO和RBAC系统,这篇就详细的来介绍一下具体实现的流程吧。
先说明一下技术栈,我们前端使用的是Ant Design Pro,后端用的express + jsonwebtoken + express-jwt
。
大致流程如下:
本来想直接扔一张时序图,但是怕自己说不清楚,就把步骤一个一个画了下来了(比较累赘,可以直接跳过看下方的实现细节)。
- 浏览器访问Node系统,Node根据cookie中的token判断是过期或不存在token。
http://sso.xxx.com/login?callback=http://ABIS.xxx.com
;
- SSO系统判断是否登录,如未登录则会停留在当前页面让用户登录,如果已登录,则跳转至
http://ABIS.xxx.com?token=asdfikj123ijajfjlkajdf
,可以开看到地址上是有带过来token的。
- 浏览器端判断地址栏是否包含token,如果有把token通过登录接口发送给Node端。
- Node端的接收到token后发送给SSO系统并带上系统标识,成功后SSO会返回给Node系统用户的详细信息,然后Node端继续发送给RBAC接口,同样带上系统标识,则会返回用户的权限信息。
- 获取用户详情和用户权限成功以后,Node端通过JWT把用户名信息加密为token,并写入到cookie中。
- 后续的接口中都会带有token信息,Node通过
expressJwt
解析token是否过期来判断是否要把接口内容转发至后端的业务系统。
- 如token有效,Node端使用
Encrypt
将要转发的信息加密为签名发送给后端。
- 后端通过私钥解密信息,获得用户信息和业务数据,执行业务操作。
- 如用户退出,浏览器端会请求Node的退出接口,我们是直接删除了cookei,没有做其他处理,后续会将未过期但提前退出的token保存至
redis
,并加上事务,到期自动删除,我们也没做token续签的功能,可以在每次token解析后判断用户的过期时间,如果小于五分钟,则生成新的token写入浏览器的cookie中。
实现细节
跨域
思路和上边的一样,不过还有好多细节需要补充,我们希望集群部署这个Node应用,并且前端和Node是分开部署的,需要对跨域做处理。
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();
}
});
token验证与登录接口排除验证
使用expressJwt
很方便,这也是我们用它的原因,而不是用jwt
在实现一遍这样的功能。
const expressJwt = require('express-jwt');
//定义签名
const secret = 'salt';
//使用中间件验证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'] //除了这些地址,其他的URL都需要验证
}));
token验证结果处理
为了方便后续接口的操作,我们需要把token中的用户信息提供给转发的方法用,
我们把解开的token挂在了req.tokenDecode
上,这一步很重要,如果验证不通过也要返回给浏览器端提示。
//拦截器
app.use(function (err, req, res, next) {
//当token验证失败时会抛出如下错误
if (err.name === 'UnauthorizedError') {
//这个需要根据自己的业务逻辑来处理( 具体的err值 请看下面)
res.status(200).send({ code: -1, msg: '未登录', status: 41002 });
}
});
// 解析token中间件
app.use((req, res, next) => {
// 获取token
let token = req.headers.token;
if (token) {
let decoded = jwt.decode(token, secret);
req.tokenDecode = decoded
}
next()
});
登录接口的实现
如果你还记得上边的流程:
- SSO返回给浏览器端token,浏览器再调用登录接口,把token发送Node端。
- Node端把token发送给SSO获取用户详情
- 成功后,Node端把用户详情发送给RBAC获取用户权限。
- Node使用用户名生成token并且写cookei,然后把用户详情和权限返回给浏览器.
这是一个大方法,在2、3的步骤中我们要带着系统标识发送给SSO,也就说SSO不是随便一个服务器带着token请求过来就会返回用户信息的,每一个系统对应的都有一个系统标识(字符串),因为这个Node应用是多项目的,所以我们把每个前端域名对应的系统标识存到了库里,浏览器端的请求代码都是通过工具生成出来的,都带有项目Id过来,我们需要根据id把系统标识查询出来,如果你不太理解这段什么意思,可以忽略掉注释中获取server详情
和获得系统标识
的部分。
因为要先获取系统标识再获取用户详情再获取用户权限,所以我们用了async await
串行操作。
//定义一个接口,返回token给客户端. 写入cookie
app.use('/login', async function (req, res) {
// 获取server详情
var getProInfo = require('./model/getProInfo.js');
// 获得系统标识
let systemName = await getProInfo.find({ _id: req.query.id }).then(rs => {
if (rs.length === 0) {
res.send({ code: -1, msg: 'token错误' });
}
return rs[0].projectName
})
let systemName = req.query.system;
// 判断用户是否登录 SSO
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
//生成token
const token = jwt.sign({
systemName,
masterName,
masterFullName
}, secret, {
expiresIn: 3600 * 2 //秒到期时间
});
// 写入cookie
res.cookie('token', token, {
maxAge: 1000 * 60 * 60 * 24 * 30,
domain:'xxx.com', //设置domain 共享当前域下面登录状态
httpOnly: true
});
res.send({ token: token, ...userInfo.data });
} else {
res.send(userInfo)
}
});
RSA加密
Node端和业务系统的通讯就是通过RSA加签来通讯了,不过这一步性能很差,每个请求都要加密和解密,居所这个签名算法很耗费性能,我们本着先上了再说的原则,先走通,哈哈,如果有好的方法也希望分享一下,一起进步。
另外,我们在浏览器端和Node端的通讯上,也加了一下md5
的验证,其实没什么用,不过我们还是加上了,算是加了个让人疑惑
的功能吧,我们给每个请求都加入了sign
字段,这个字段只是把要发送的内容生成了一个字符串,Node端再验证一下sign
是否一致。
md5加密 在浏览器端把发送的内容生成一个字符串,并作为sign自动发送出去
// 前端项目的request.js中 生成 sign
import hash from 'hash.js';
const fingerprint = url + (options.body ? JSON.stringify(options.body) : '');
const sign = hash
.sha256()
.update(fingerprint)
.digest('hex');
Node的md5验证与RSA加密
这一段代码的上半部分是md5
的对比验证,把数据重新生成md5
字符串,并与请求中的sign
字段对比。
下半部分是RSA加密,其中一个生成的工具方法./../utils/param
在这段代码的下方贴出来。
var express = require('express');
var router = express.Router();
var hash = require('hash.js');
var request = require('request');
// 封装参数 加密
const Param = require('./../utils/param');
/* GET home page. */
router.post('/', async function(req, res, next) {
if(req.body.sign){
let { sign, ...body } = req.body
const signCode = hash
.sha256()
.update(body,'utf8')
.digest('hex');
// md5加密对比
if(signCode === sign){
let { data, url } = body
console.log(data,typeof data,'data--------');
// 统一封装数据
let postData = Param(data);
// 地址正则匹配
url = url.replace(/\$\{(\w+)\}/, ($0, $1) => {
<!--环境变量判断 可忽略-->
if (process.env === 'prod') {
return 'http://ABIS.xxx.com'
} else if (process.env === 'fat') {
return 'http://ABIS.fat.xxx.com'
} else {
return 'http://ABIS.uat.xxx.com'
}
});
// 请求接口
request({
url: url,
method: "POST",
json: true,
headers: {
"content-type": "application/json",
},
body: postData
}, (error, response, body) => {
if (!error && response.statusCode == 200) {
res.send(response.body)
}else{
res.status(response.statusCode).send({code: -1, msg: '非法请求'})
}
});
}else{
res.send({ code:-1,msg:'非法参数' });
}
}else{
res.send({ code:-1,msg:'非法参数' });
}
});
module.exports = router;
加密方法
Param.js为封装与后端通讯数据格式,这块中的用户名应为token中解析的用户名,系统标识数据库中查询的系统标识,现用****
代替。
// 生成签名
const Encrypt = require('./encrypt');
module.exports = function (data) {
const newParam = {
username: '*****',
system: '****',
time: Date.now(),
random: Math.random(),
data,
};
// 生成签名
const sign = new Encrypt(newParam).value;
newParam.sign = sign;
return newParam;
};
encrypt.js 为加密方法
'use strict';
/**
* 用密钥对文本做签名
*/
// 转换对象为query字符串
const sortAndQuery = require('./sortAndQuery');
const NodeRSA = require('node-rsa');
// 签名用的密钥
const private_key_data = '-----BEGIN RSA PRIVATE KEY-----\n' +
'MIICWwIBAAKBgQCD4EalJIz4YMGrj6oARl30Rji7cH9mzW2p2sNIUmNb48TeR7WN\n' +
'IkkUf61VzVxk/K6taQJAc74f49zfD62u0sCcODS3UVUs7c/wEMZE7lmRlx/RQgSE\n' +
'XZYS/Rq+kbkjfb8DWZLVguU1+owiwogUsdwmD4WaMw==\n' +
'-----END RSA PRIVATE KEY-----';
// 生成RSA密钥对象
const private_key = new NodeRSA(private_key_data);
class Encrypt {
constructor(params) {
if (params) {
// 如果new对象时就有传入参数,则调用签名方法,返回签名结果,因为class构造函数只能返回对象,所以...
const signStr = this.sign(params);
return {
toString: () => signStr,
value: signStr,
};
}
}
// 对参数对象做签名,返回生成的签名
sign(params) {
// 先把对象转成query字符串,再做签名
const queryStr = sortAndQuery(params);
const sign = private_key.sign(queryStr, 'base64', 'utf8');
return sign;
}
}
module.exports = Encrypt;
总结
基本上算是淌水了一遍JWT
和RSA
,JWT
要相对简单一些,RSA
自己理解的还不是很好,后期有实践的话继续深入吧,如果最近一周有实践,希望能够把上篇笔记的拖拽实现和模块划分写一下,见上篇开发一个前端系统生成工具的实现思路
。希望多多督促多多交流哈,给个赞吧😘。