Node.js 使用JWT對接SSO

在上一篇筆記中,提到使用Node.js作了中間層,對接SSO和RBAC系統,這篇就詳細的來介紹一下具體實現的流程吧。前端

先說明一下技術棧,咱們前端使用的是Ant Design Pro,後端用的express + jsonwebtoken + express-jwtnode

大體流程以下:

原本想直接扔一張時序圖,可是怕本身說不清楚,就把步驟一個一個畫了下來了(比較累贅,能夠直接跳過看下方的實現細節)。web

  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是分開部署的,須要對跨域作處理。redis

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()
});

複製代碼

登陸接口的實現

若是你還記得上邊的流程:express

  1. SSO返回給瀏覽器端token,瀏覽器再調用登陸接口,把token發送Node端。
  2. Node端把token發送給SSO獲取用戶詳情
  3. 成功後,Node端把用戶詳情發送給RBAC獲取用戶權限。
  4. Node使用用戶名生成token而且寫cookei,而後把用戶詳情和權限返回給瀏覽器.

這是一個大方法,在二、3的步驟中咱們要帶着系統標識發送給SSO,也就說SSO不是隨便一個服務器帶着token請求過來就會返回用戶信息的,每個系統對應的都有一個系統標識(字符串),由於這個Node應用是多項目的,因此咱們把每一個前端域名對應的系統標識存到了庫裏,瀏覽器端的請求代碼都是經過工具生成出來的,都帶有項目Id過來,咱們須要根據id把系統標識查詢出來,若是你不太理解這段什麼意思,能夠忽略掉註釋中獲取server詳情得到系統標識的部分。json

由於要先獲取系統標識再獲取用戶詳情再獲取用戶權限,因此咱們用了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加簽來通信了,不過這一步性能不好,每一個請求都要加密和解密,居所這個簽名算法很耗費性能,咱們本着先上了再說的原則,先走通,哈哈,若是有好的方法也但願分享一下,一塊兒進步。api

另外,咱們在瀏覽器端和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本身理解的還不是很好,後期有實踐的話繼續深刻吧,若是最近一週有實踐,但願可以把上篇筆記的拖拽實現和模塊劃分寫一下,見上篇開發一個前端系統生成工具的實現思路 。但願多多督促多多交流哈,給個贊吧😘。

相關文章
相關標籤/搜索