Node.js 使用JWT对接SSO

4,333 阅读8分钟

在上一篇笔记中,提到使用Node.js做了中间层,对接SSO和RBAC系统,这篇就详细的来介绍一下具体实现的流程吧。

先说明一下技术栈,我们前端使用的是Ant Design Pro,后端用的express + jsonwebtoken + express-jwt

大致流程如下:

本来想直接扔一张时序图,但是怕自己说不清楚,就把步骤一个一个画了下来了(比较累赘,可以直接跳过看下方的实现细节)。

  1. 浏览器访问Node系统,Node根据cookie中的token判断是过期或不存在token。

2. token失效或不存在token,则会在接口中返回用户未登录,前端会根据状态码跳转到指定的SSO登录地址,即SSO的指定地址,例如:http://sso.xxx.com/login?callback=http://ABIS.xxx.com;

  1. SSO系统判断是否登录,如未登录则会停留在当前页面让用户登录,如果已登录,则跳转至http://ABIS.xxx.com?token=asdfikj123ijajfjlkajdf,可以开看到地址上是有带过来token的。

  1. 浏览器端判断地址栏是否包含token,如果有把token通过登录接口发送给Node端。

  1. Node端的接收到token后发送给SSO系统并带上系统标识,成功后SSO会返回给Node系统用户的详细信息,然后Node端继续发送给RBAC接口,同样带上系统标识,则会返回用户的权限信息。

  1. 获取用户详情和用户权限成功以后,Node端通过JWT把用户名信息加密为token,并写入到cookie中。

  1. 后续的接口中都会带有token信息,Node通过 expressJwt解析token是否过期来判断是否要把接口内容转发至后端的业务系统。

  1. 如token有效,Node端使用Encrypt将要转发的信息加密为签名发送给后端。

  1. 后端通过私钥解密信息,获得用户信息和业务数据,执行业务操作。

  1. 如用户退出,浏览器端会请求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()
});

登录接口的实现

如果你还记得上边的流程:

  1. SSO返回给浏览器端token,浏览器再调用登录接口,把token发送Node端。
  2. Node端把token发送给SSO获取用户详情
  3. 成功后,Node端把用户详情发送给RBAC获取用户权限。
  4. 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;

总结

基本上算是淌水了一遍JWTRSAJWT要相对简单一些,RSA自己理解的还不是很好,后期有实践的话继续深入吧,如果最近一周有实践,希望能够把上篇笔记的拖拽实现和模块划分写一下,见上篇开发一个前端系统生成工具的实现思路 。希望多多督促多多交流哈,给个赞吧😘。