使用node从服务端开始实现 jwt登录鉴权+无感刷新token

整个完整demo地址为github

jwt登录鉴权

jwt(jsonwebtoken)主要用在登录流程中,用于标识用户,下面将完整实现一下基于jwt和rsa非对称加密的用户登录鉴权流程:

  1. 用户输出账号密码
  2. 使用公钥对帐号密码加密
  3. 发送登录请求
  4. 登录成功,返回token
  5. 前端将token存储在本地
  6. 之后的请求都需要携带上这个token

服务端

先用node整好服务端,对整个流程分析: 前端要加密用户信心,就要使用密钥,那么后端应该先提供密钥接口:

arduino 复制代码
当前文件夹结构
keys
--- index.js
--- private.pem  // 存放私钥
--- public.pem   // 存放公钥

在index,需要什么捏,想想哈,首先要有生成公钥和私钥的方法createKeys,其次还需要把生成的公钥和私钥保存在文件中的方法setPubKeyPemsetPrivateKeyPem,ok,现在已经可以创建和保存密钥了,那么接下来就差获取密钥的方法了,获取密钥就叫getPrivateKeyPemgetPubKeyPem吧,等等,当用户登录了,我们获取的是加密后的信息,应该还需要一个解密的方法,就叫privateDecrypt吧.这下差不多了,先把这些方法实现以下:

ini 复制代码
const fs = require('fs');
const crypto = require('crypto'); // 自带模块
const path = require('path')
​
const createKeys = () => {
      // 生成新的RSA密钥对
    const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
      modulusLength: 2048,
    });
    // 将公钥转换为PEM格式的字符串
    const publicKeyPem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString();
    const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }).toString();
    return { publicKeyPem, privateKeyPem }
}
​
const getPubKeyPem = () => {
  // 读取公钥文件
  const filepath = path.join(__dirname, 'public.pem');
  const publicKey = fs.readFileSync(filepath, 'utf8');
  if(!publicKey){
    const { publicKeyPem, privateKeyPem } = createKeys()
    setPubKeyPem(publicKeyPem)
    setPrivateKeyPem(privateKeyPem)
    return publicKeyPem
  }
  return publicKey;
}
​
const setPubKeyPem = (pubKey) => {
  // 写入公钥文件
  const filepath = path.join(__dirname, 'public.pem');
  fs.writeFileSync(filepath, pubKey);
}
​
const setPrivateKeyPem = (priKey) => {
  // 写入私钥文件
  const filepath = path.join(__dirname, 'private.pem');
  fs.writeFileSync(filepath, priKey);
}
​
const getPrivateKeyPem = () => {
  // 读取私钥文件
  const filepath = path.join(__dirname, 'private.pem');
  const privateKey = fs.readFileSync(filepath, 'utf8');
  if(!privateKey){
    const { publicKeyPem, privateKeyPem } = createKeys()
    setPubKey(publicKeyPem)
    setPrivateKey(privateKeyPem)
    return privateKeyPem
  }
  return privateKey;
}
​
function privateDecrypt(encrypted){
  const privateKeyPem = getPrivateKeyPem();
  const privateKey = crypto.createPrivateKey(privateKeyPem)
  const encryptedData = Buffer.from(encrypted, 'base64');
  // 使用私钥进行解密
  const decryptedBuffer = crypto.privateDecrypt(
    {
      key: privateKey,
      padding: crypto.constants.RSA_PKCS1_PADDING, // 根据加密时的填充方式选择
    },
    encryptedData
  );
  return JSON.parse(decryptedBuffer.toString('utf8'))
}
​
// 最后将这些方法,暴露出去
module.exports = {
  getPubKeyPem,
  getPrivateKeyPem,
  privateDecrypt
}

密钥的模块搞定了,接下来加一个接口就可以了

arduino 复制代码
当前文件夹结构
keys
--- index.js
--- private.pem  // 存放私钥
--- public.pem   // 存放公钥
server.js        +

在server.js中直接使用express搭建一个小服务,先安装expressnpm i express -S,之后:

javascript 复制代码
const http = require('http');
const express = require('express');
const {getPubKeyPem,privateDecrypt} = require('./keys')
​
// 使用Express创建HTTP服务器
const app = express();
app.use(express.json());
​
// 获取密钥
app.get('/publicKey', (req, res) => {
  res.set('Content-Type', 'application/x-pem-file');
  const pub_key = getPubKeyPem();
  res.send({
    data: {pub_key},
    err: null,
    success:true
  });
});
​
// 启动服务器监听3000端口
const server = http.createServer(app);
server.listen(3000, () => {
  console.log('Server is running on port 3000...');
});

OK,密钥这块搞定了,接着分析流程,前端加密完成了,接下来该发送登录请求了,那么后端需要提供登录接口,如果用户登录成功,接口中还应该签发token才行,那就从token下手吧:

arduino 复制代码
当前文件夹结构
keys
--- index.js
--- private.pem  // 存放私钥
--- public.pem   // 存放公钥
server.js
jwt.js           +

安装一下jsonwebtoken: npm i jsonwebtoken -S

jwt.js中应该干些什么呢? 首先需要一个签发token的方法generateToken,现在有了token,之后的请求中肯定需要校验token,所以还需要一个校验token的方法authenticateToken,感觉差不多了,来实现一下:

javascript 复制代码
const jwt = require('jsonwebtoken');
const {getPrivateKeyPem} = require('./keys')
const secret = getPrivateKeyPem(); // 你的密钥
​
// 设置token过期时间
const options = {
  expiresIn: '10s', // 可以是数字(单位为秒)或字符串(如 '2 hours'、'1d' 等)
  algorithm: 'RS256' // 使用RAS非对称加密进行签名
};
​
// 生成并签发JWT令牌
function generateToken(user) {
  const payload = { username: user.username };
  return jwt.sign(payload, secret, options);
}
​
// 一个中间件,校验token时间
function authenticateToken(req, res, next) {
  if(['/login','/getPubKey'].includes(req.url)){ // 登录和获取密钥不校验token
    next()
  }
  const authHeader = req.headers['authorization'];
  if (authHeader) {
    const token = authHeader.split(' ')[1]; // 获取Bearer后面的token值
    try {
      const decoded = jwt.verify(token, secret); // 使用密钥验证Token
      console.log("decoded:",decoded)
      req.user = decoded; // 将解码后的用户信息添加到req对象以便后续路由使用
      console.log("当前时间",Date.now())
      next(); // 如果验证通过,继续执行下一个中间件或路由处理程序
    } catch (err) {
      return res.status(401).json({ message: 'Unauthorized: Invalid token.' });
    }
  } else {
    return res.status(401).json({ message: 'Unauthorized: No token provided.' });
  }
}
​
module.exports = {
  generateToken,
  authenticateToken
};

server.js中新增一个接口

php 复制代码
const {generateToken,authenticateToken} = require('./jwt')
​
app.post('/login', (req, res) => {
  // 获取post请求的参数进行解密
  const {username,password} = privateDecrypt(req.body.encrypted)
  if(username === 'admin',password === 'admin'){ // 整个模拟数据
    // 登录成功,签发token
    const token = generateToken({username}) // 签发token的时候把用户名带上
    res.send({
      data: {token},
      err: null,
      success:true
    });
    return
  }
  res.send({
    data: '',
    err: "没有找到用户",
    success:false,
    code: 10034
  });
});

ok,服务端就这样差不多了,只要能满足前端测试就好,接下来搞一下前端

前端

先把基础的结构写一下,这里我直接clone之前写的项目模板react-template,配置一下路由,布局等,然后写一下登录页:

ini 复制代码
login.tsx:
    import React from 'react';
    import { Button, Form, Input } from 'antd';
​
    const onFinishFailed = (errorInfo: any) => {
      console.log('Failed:', errorInfo);
    };
​
  const onFinish = async (values: any) => {};
​
  return (
    <Form
      name="basic"
      labelCol={{ span: 8 }}
      wrapperCol={{ span: 16 }}
      style={{ maxWidth: 600 }}
      initialValues={{ remember: true }}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
      autoComplete="off"
    >
      <Form.Item<FieldType>
        label="Username"
        name="username"
        rules={[{ required: true, message: 'Please input your username!' }]}
      >
        <Input />
      </Form.Item>
​
      <Form.Item<FieldType>
        label="Password"
        name="password"
        rules={[{ required: true, message: 'Please input your password!' }]}
      >
        <Input.Password />
      </Form.Item>
​
      <Form.Item wrapperCol={{ offset: 8, span: 16 }}>
        <Button
          type="primary"
          htmlType="submit"
        >
          Submit
        </Button>
      </Form.Item>
    </Form>
  );
};
​
export default Login;

首先需要把后端提供的接口,前端写出对应的请求方法,在service下新建login.ts用来存放所有的登录相关接口:

kotlin 复制代码
import { get, post } from './http';

export const getPubKey = () => {
  return get('/api/publicKey');
};
export const login = (data: object) => {
  return post('/api/login', data);
};

接着分析,用户点击登录之后,首先是对用户信息加密,加密的话先要有密钥,先把加密这部分整一下: 安装一个加密库: npm i jsencrypt -S

common             
├─ encrypt.ts  + 

encrypt.ts中首先需要一个获取密钥的方法getRsaKey,之后需要根据密钥进行加密的方法encryptParam:

typescript 复制代码
import JSEncrypt from 'jsencrypt';
import { setKeyInLocal, getKeyByLocal } from '@/common/keyAndToken';
import { getPubKey } from '@/service/login';

export const getRsaKey = async () => {
  const key = getKeyByLocal();
  if (['undefined', null, undefined].includes(key)) {
    const [data, err] = await getPubKey();
    if (err) return;
    setKeyInLocal(data.pub_key);
    return data.pub_key;
  }
  return key;
};

export const encryptParam = async (param: object) => {
  const key = await getRsaKey();
  const encryptor = new JSEncrypt();
  encryptor.setPublicKey(key);
  return encryptor.encrypt(JSON.stringify(param));
};

加密完成了,接下来就是发送登录请求,并存储token了

在/pages/login中,完善一下onFinish方法,

typescript 复制代码
import React from 'react';
import { Button, Form, Input } from 'antd';
import { login } from '@/service/login';
import { useNavigate } from 'react-router-dom';
import { setTokenInLocal, setRefreshTokenInLocal } from '@/common/keyAndToken';
import { encryptParam } from '@/common/encrypt.ts';
type FieldType = {
  username?: string;
  password?: string;
  remember?: string;
};

const onFinishFailed = (errorInfo: any) => {
  console.log('Failed:', errorInfo);
};

const Login: React.FC = () => {
  const navigate = useNavigate();

  const queryUserInfo = async () => {
    const [data, err] = await queryUser();
    if (err || !data) return;
    console.log(data);
  };

  const onFinish = async (values: any) => {
    const encrypted = await encryptParam(values);
    const [data, err] = await login({ encrypted });
    if (err || !data) return;
    setTokenInLocal(data.token);
    localStorage.setItem('user', values.username); // 用于和token中携带的name比较判断用户登录状态
    navigate('主页的路由');
  };

ok,整个登录就基本完成了,例子中的有些方法比较简单,文章中没有直接展示如:setTokenInLocal, setRefreshTokenInLocal

无感刷新token

背景: 在项目中,token的过期时间如果设置的太长,出于安全性考虑: 万一token被盗取,影响的时间要更大,但是如果token的时间过短,用户需要频繁登录,出于用户体验考虑,也不合适.

因此就有了无感刷新token的功能: 使用两个token: accessToken和refreshToken,

  • accessToken用来鉴权,其有效期很短,即使被盗,影响也较小
  • refreshToken只用来刷新accessToken的时间.其有效期很长,作用只是用来刷新token,即使被盗影响也不大

流程就是: 用户发起请求,发现token已经过期时,自动携带refreshToken请求新的accessToken和refreshToken,r然后重新发起刚才因为token失效而未成功的请求.以此做到在用户无感知的情况下刷新token

注意点

  1. 如何重新发送在token刷新期间,由于token过期产生的新请求,并且使结果正确返回至对应的请求发出点
  2. 携带refreshToken的请求也可能过期,但这个过期请求不需要刷新处理

实现步骤

服务端

对之前写的node服务修改一下,首先新增签发refrehsToken的接口

jwt.js中新增:

java 复制代码
// 无感刷新token所以来的token
const refreshOptions = { 
  expiresIn: '10d',      
  algorithm: 'RS256'     
}                        

// 签发无感刷新使用的token
function generateReFreshToken(user) {
  const payload = { username: user.username };
  return jwt.sign(payload, secret, refreshOptions);
}

module.exports = {
  ...
  generateReFreshToken
};

server.js中新增刷新接口:

php 复制代码
const {generateToken,generateReFreshToken,authenticateToken} = require('./jwt')
// 无感刷新token,authenticateToken用上之前写的鉴权中间件,鉴别token是否有效
app.get('/refreshToken',authenticateToken,(req,res) => {
  const {username,password} = req.user
  // 新token
  const token = generateToken({username,password})
  // 新refreshToken
  const refreshToken = generateReFreshToken({username,password})
  res.send({
    data: {
      token,refreshToken
    },
    success: true,
  })
})
//登录接口也要改一下 登录时将refreshToken也返回
app.post('/login', (req, res) => {
  // 获取post请求的参数进行解密
  const {username,password} = privateDecrypt(req.body.encrypted)
  if(username === 'admin',password === 'admin'){
    // 登录成功,签发token
    const token = generateToken({username})
    const refreshToken = generateReFreshToken({username}) +
    res.send({
      data: {token,refreshToken}, +
      err: null,
      success:true
    });
    return
  }
  res.send({
    data: '',
    err: "没有找到用户",
    success:false,
    code: 10034
  });
});

前端

主要在axios的响应拦截器和请求拦截器中做文章

  1. 定义一个变量: isRefreshTokening用来判断是否正在请求新token

  2. 定义一个队列: watingQueue存放刷新token期间,由于token过期产生的新出错请求

  3. 在请求中拦截器根据isRefreshTokening决定本次请求是否携带refreshToken,为true表示本次请求拦截器拦截的是去刷新token的请求,应该带上refreshToken

    ini 复制代码
    const service = axios.create({
      baseURL,
      timeout,
      withCredentials
    });
    
    service.interceptors.request.use(config => {
        ....
        config = handleAuth(config, isRefreshTokening); // 添加token
        ...
      return config;
    });
  4. 响应拦截器中,判断响应状态码是否为401以及url是否为刷新token的请求:/api/refreshToken

    ini 复制代码
    service.interceptors.response.use(
      res => {
        if (res.status === 200) {
          handleAuthError(res);
          return Promise.resolve(res.data.data);
        }
        return Promise.reject(res);
      },
      async err => {
        const needRefreshToken = err.response.status === 401 && err.config.url !== '/api/refreshToken';
        if (needRefreshToken) { // 表示需要刷新token
          return await silentTokenRefresh(err);
        }
        handleNetErr(err);
    
        return Promise.reject(err);
      }
    );
  5. 如果请求是/api/refreshToken且响应状态码为401表示refreshToken也过期了,只能重新登录

  6. 如果不是上述条件,则表示需要刷新token,进入silentTokenRefresh的逻辑

    arduino 复制代码
    async function silentTokenRefresh(err: any) {
      const { config } = err;
      if (!isRefreshTokening) {
        return await startRefresh(config);
      }
      return waitingRefresh(config);
    }
  7. 如果isRefreshTokeningtrue,表示正在刷新token,该次请求需要存储起来,等待token刷新完毕,重新请求

    1. 进入waitingRefresh逻辑:

      arduino 复制代码
      function waitingRefresh(config: InternalAxiosRequestConfig<any>) {
        return new Promise(resolve => {
          watingQueue.push({ config, resolve });
        });
      }

      这里返回的是一个新的promise,是为了保证该次请求的状态不结束,在发送请求的地方如: await getUserInfo()其是一个等待状态的promis,当响应拦截器返回promise.resolve或者promise.reject时,await getUserInfo()的状态才变为resolve或reject,但由于该请求失败了,并且我们需要重试这个请求,所以返回一个新的等待状态的promise,来维持await getUserInfo()的等待状态,那么await getUserInfo()的状态就取决于新的promise的状态,这个新的promise的resolve存储在队列中,等到接下来重新发送请求时,这个新的promise状态又依赖于新请求触发的响应拦截器返回的状态.这样,最终await getUserInfo()的状态相当于依赖重新发送请求触发的响应拦截器的状态,由于中间夹了一个新的promise,通过操作这个promise,就能实现保持await getUserInfo()的状态,直到重新发送的请求响应了再更新.

      可能有点绕,通俗点讲就是当请求token失效时,让这个请求先暂停,等新token回来了,再继续请求

  8. 如果isRefreshTokeningfalse,表示需要刷新token

    1. 进入startRefresh开始刷新token的逻辑

      scss 复制代码
      async function startRefresh(config: InternalAxiosRequestConfig<any>) {
        await refreshToken(); // 请求新的token
        tryWatingRequest();
        return service(config); //第一个发现token失效的请求,直接重新发送
      }
      
      // 请求新token,并更新本地
      async function refreshToken() {
        isRefreshTokening = true;
        const [data, err] = await getRefreshToken();
        if (err) return;
        const { token, refreshToken } = data;
        setTokenInLocal(token);
        setRefreshTokenInLocal(refreshToken);
        isRefreshTokening = false;
      }
      // 重新发送由于token过期存储的请求
      function tryWatingRequest() {
        while (watingQueue.length > 0) {
          const { config, resolve } = watingQueue.shift() as watingQueueTyp;
          resolve(service(config)); // 记得上面waitingRefresh中新创建的promise吗,其resolve在这里使用了,这样该promise的状态就依赖新请求的响应拦截器的返回状态了
        }
      }

完整的源码如下,其中用到的工具函数如handleAuth比较简单这里就省略了:

axios.ts

typescript 复制代码
import axios, { InternalAxiosRequestConfig } from 'axios';
import { handleNetErr, handleAuthError, handleRequestHeader, handleAuth } from './httpTools';
import { serviceConfig } from './config.ts';
interface watingQueueTyp {
  resolve: (value: any) => void;
  config: InternalAxiosRequestConfig<any>;
}

let isRefreshTokening = false;
const watingQueue: watingQueueTyp[] = [];
const { baseURL, useTokenAuthorization, timeout, withCredentials } = serviceConfig;
const service = axios.create({
  baseURL,
  timeout,
  withCredentials
});

service.interceptors.request.use(config => {
  config = handleRequestHeader(config, {}); // 其他调整
  if (useTokenAuthorization) {
    config = handleAuth(config, isRefreshTokening); // 添加token
  }

  return config;
});

service.interceptors.response.use(
  res => {
    if (res.status === 200) {
      handleAuthError(res);
      return Promise.resolve(res.data.data);
    }

    return Promise.reject(res);
  },
  async err => {
    const needRefreshToken = err.response.status === 401 && err.config.url !== '/api/refreshToken';
    if (needRefreshToken) {
      return await silentTokenRefresh(err);
    }
    handleNetErr(err);

    return Promise.reject(err);
  }
);

export default service;

import { getRefreshToken } from '../login.ts';
import { setRefreshTokenInLocal, setTokenInLocal } from '@/common/keyAndToken.ts';

// 无感刷新token
async function silentTokenRefresh(err: any) {
  const { config } = err;
  if (!isRefreshTokening) {
    return await startRefresh(config);
  }
  return waitingRefresh(config);
}

// 开始刷新token
async function startRefresh(config: InternalAxiosRequestConfig<any>) {
  await refreshToken();
  tryWatingRequest();
  return service(config); //该配置对应的请求第一次发现了token失效,直接重新发送
}

// 正在刷新token,将当前请求存储
function waitingRefresh(config: InternalAxiosRequestConfig<any>) {
  return new Promise(resolve => {
    //存储刷新期间失败的请求,返回一个新的promise,保持该次请求的状态为等待,不让这次请求结束,使结果正确返回至对应的请求发出点
    watingQueue.push({ config, resolve });
  });
}

// 请求新token,并更新本地
async function refreshToken() {
  isRefreshTokening = true;
  const [data, err] = await getRefreshToken();
  if (err) return;
  const { token, refreshToken } = data;
  setTokenInLocal(token);
  setRefreshTokenInLocal(refreshToken);
  isRefreshTokening = false;
}

// 重新发送由于token过期存储的请求
function tryWatingRequest() {
  while (watingQueue.length > 0) {
    const { config, resolve } = watingQueue.shift() as watingQueueTyp;
    resolve(service(config));
  }
}
相关推荐
以对_10 分钟前
uview表单校验不生效问题
前端·uni-app
Zheng1131 小时前
【可视化大屏】将柱状图引入到html页面中
javascript·ajax·html
程序猿小D1 小时前
第二百六十七节 JPA教程 - JPA查询AND条件示例
java·开发语言·前端·数据库·windows·python·jpa
john_hjy1 小时前
【无标题】
javascript
奔跑吧邓邓子2 小时前
npm包管理深度探索:从基础到进阶全面教程!
前端·npm·node.js
软件开发技术深度爱好者2 小时前
用HTML5+CSS+JavaScript庆祝国庆
javascript·css·html5
前端李易安2 小时前
ajax的原理,使用场景以及如何实现
前端·ajax·okhttp
汪子熙2 小时前
Angular 服务器端应用 ng-state tag 的作用介绍
前端·javascript·angular.js
Envyᥫᩣ3 小时前
《ASP.NET Web Forms 实现视频点赞功能的完整示例》
前端·asp.net·音视频·视频点赞
Мартин.7 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss