node.js如何实现双 Token + Cookie 存储 + 无感刷新机制

发布于:2025-05-22 ⋅ 阅读:(20) ⋅ 点赞:(0)

node.js如何实现双 Token + Cookie 存储 + 无感刷新机制

为什么要实施双token机制?

优点 描述
安全性 Access Token 短期有效,降低泄露风险;Refresh Token 权限受限,仅用于获取新 Token
用户体验 用户无需频繁重新登录,Token 自动刷新过程对用户透明
灵活性 独立控制不同 Token 的生命周期,适应各种场景需求
可管理性 支持多设备登录管理,便于撤销特定设备的登录状态
性能优化 减少数据库查询次数,提升系统响应速度

实现方案:

模块 实现方式
登录接口 返回 accessTokenrefreshToken,分别存入 Cookie
Access Token 短时效 JWT,用于请求鉴权
Refresh Token 长时效 JWT,用于刷新 Access Token
Token 校验方式 后端从 Cookie 中读取 token(即 Access Token)
前端 Axios 使用响应拦截器统一处理 Token 失效和自动刷新
  • 使用 JWT 生成两个 Token:
    • Access Token(短时效):用于接口认证,例如有效期为 15 分钟
    • Refresh Token(长时效):用于刷新 Access Token,例如有效期为 7 天
  • 在用户登录时返回这两个 Token,并将 Refresh Token 存储在数据库中
  • 当 Access Token 过期后,客户端使用 Refresh Token 请求新的 Access Token
  • 如果 Refresh Token 也过期或无效,则强制重新登录

具体代码实现

1. 安装依赖:

cookie-parser用来解析 Cookie 中的 Token

npm install jsonwebtoken bcryptjs cookie-parser

2. 数据库添加两个字段

refresh_token VARCHAR(255) 加密后的 RefreshToken
expires_at DATETIME RefreshToken 过期时间

3. 在后端cors跨域中间中添加属性

// 将cors注册为全局中间件
app.use(cors({
  origin: 'http://localhost:5173', // 前端地址
  credentials: true // 👈 允许携带凭证(cookies)
}))

3. 登录逻辑改造(添加双token)

  1. 添加配置文件config.js
module.exports = {
  jwtSecretKey: 'yke;eky1]239_jwt87-2up34',
  refreshTokenSecretKey: 'yke;eky1]239_refresh87-2up34',
  accessExpiresIn: '15m',  // 访问令牌有效期
  refreshExpiresIn: '7d',   // 刷新令牌有效期
  accessExpiresInSec: 15 * 60,  // 秒数
  refreshExpiresInSec: 7 * 24 * 60 * 60  // 秒数
}
  1. jwt生成accessToken访问token、refreshToken刷新token
// 生成access token
const accessToken = jwt.sign(
  { id: user.id, username: user.username, email: user.email },
  config.jwtSecretKey,
  { expiresIn: config.accessExpiresIn }
)
// 生成refresh token
const refreshToken = jwt.sign(
  { id: user.id, username: user.username, email: user.email },
  config.refreshTokenSecretKey,
  { expiresIn: config.refreshExpiresIn }
)
  1. 生成token过期时间,和refreshToken一起存入数据库
 const expiresAt = new Date()
 expiresAt.setSeconds(expiresAt.getSeconds() + config.refreshExpiresInSec)
  1. 将accessToken访问token、refreshToken刷新token存入cookie
// 设置cookie
res.cookie('token', accessToken, {
    maxAge: config.accessExpiresInSec * 1000,
    httpOnly: true,
    secure: true,
    path: '/'
})
res.cookie('refresh_token', refreshToken, {
    maxAge: config.refreshExpiresInSec * 1000,
    httpOnly: true,
    secure: true,
    path: '/api/user/refresh-token',   // 限制路径提高安全性
    sameSite: 'none'
})

登录逻辑完整代码:

// 用户登录的处理函数
exports.login = (req, res) => {
  // 接收表单数据
  const userInfo = req.body
  console.log(userInfo)
  // 查询用户信息
  const sqlStr_name = 'select * from user where username=?'
  db.query(sqlStr_name, [userInfo.username], (err, results) => {
    if (err) {
      return res.send({ status: 1, message: err })
    }
    // 执行sql语句成功,但是获取的条数不等于1
    if (results.length === 0) {
      return res.send({ status: 1, message: '该用户不存在' })
    }
    // 判断密码是否正确
    const cmpresult = bcrypt.compareSync(userInfo.password, results[0].password)
    if (!cmpresult) {
      return res.send({ status: 1, message: '密码错误' })
    }
    // 在服务器端生成Token字符串
    const user = { ...results[0] }
    // 生成access token
    const accessToken = jwt.sign(
      { id: user.id, username: user.username, email: user.email },
      config.jwtSecretKey,
      { expiresIn: config.accessExpiresIn }
    )
    // 生成refresh token
    const refreshToken = jwt.sign(
      { id: user.id, username: user.username, email: user.email },
      config.refreshTokenSecretKey,
      { expiresIn: config.refreshExpiresIn }
    )
    // 将refresh token存储到数据库中
    const expiresAt = new Date()
    expiresAt.setSeconds(expiresAt.getSeconds() + config.refreshExpiresInSec)
    const sqlStr_refreshToken = 'update user set refresh_token=?, expires_at=? where id=?'
    db.query(sqlStr_refreshToken, [refreshToken, expiresAt, user.id], (err) => {
      if (err) {
        console.error('保存refreshToken失败:', err)
        return res.send({ status: 1, message: '保存refreshToken失败' })
      }
      // 设置cookie
      res.cookie('token', accessToken, {
        maxAge: config.accessExpiresInSec * 1000,
        httpOnly: true,
        secure: true,
        path: '/'
      })
      res.cookie('refresh_token', refreshToken, {
        maxAge: config.refreshExpiresInSec * 1000,
        httpOnly: true,
        secure: true,
        path: '/api/user/refresh-token',   // 限制路径提高安全性
        sameSite: 'none'
      })
      res.send({
        status: 0,
        message: '登录成功',
        data: {
          username: results[0].username
        }
      })
    })  
  })
}

4. 实现token刷新接口

创建新路由/refreshToken

// token刷新接口
exports.refreshToken = (req, res) => {
  // 直接从cookie中获取刷新token => 前端不需要再单独把token传入请求头
  const refreshToken = req.cookies.refresh_token
  // 判断refresh token是否存在
  if (!refreshToken) {
    return res.send({ status: 1, message: '缺少refreshToken,请先登录' })
  }
  try {
    // 验证refreshToken
    const decoded = jwt.verify(refreshToken, config.refreshTokenSecretKey)
    // 查询用户是否存在且refreshToken匹配
    const sql = 'select * from user where id=? and refresh_token=?'
    db.query(sql, [decoded.id, refreshToken], (err, results) => {
      if (err) {
        return res.send({ status: 1, message: '无效的refreshToken' + err.message })
      }
      const user = results[0]
      // 生成新的access token
      const accessToken = jwt.sign(
        { id: user.id, username: user.username, email: user.email },
        config.jwtSecretKey,
        { expiresIn: config.accessExpiresIn }
      )
      // 更新accessToken到Cookie
      res.cookie('token', accessToken, {
        maxAge: config.accessExpiresInSec * 1000,
        httpOnly: true,
        secure: true,
        path: '/'
      })
      res.send({
        status: 0,
        message: 'accessToken刷新成功',
        data: {
          token: accessToken
        }
      })
    })
  } catch (error) {
    return res.status(403).send({ status: 1, message: 'token已过期,请重新登录' })
  }
}

5. 响应拦截器中处理token

import axios from 'axios'
import { message } from 'antd'
import { refreshTokenService } from '@/api/user'

const instance = axios.create({
  baseURL: 'http://localhost:3333',  // 你的API服务器地址
  timeout: 10000,  // 请求超时时间
  headers: {
    'Content-Type': 'application/json'
  },
  // 必须加上这个选项才能跨域携带
  withCredentials: true
})

// 添加请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 后端将token存在了cookie中,这里不需要携带token
    return config
  },
  (err) => Promise.reject(err)
)

// 标记是否正在刷新 Token(防止并发刷新)
let isRefreshing = false
// 保存所有因 Token 失效而等待新 Token 的请求回调函数
let refreshSubscribers = []
// 成功获取到新的 Token 后,执行所有等待的请求
function onRefreshed(newToken) {
  refreshSubscribers.forEach((cb) => cb(newToken))
  refreshSubscribers = []
}
// 将等待刷新 Token 的请求封装成一个回调函数,加入队列中
function addRefreshSubscriber(callback) {
  refreshSubscribers.push(callback)
}
// 响应拦截器
instance.interceptors.response.use(
  (res) => {
    console.log(res) 
    // 摘取核心响应数据
    if (res.data.status === 0) {
      return res
    }
    // 处理业务失败
    message.error({type: 'error', content: res.data.message || '服务异常'})
    return Promise.reject(res.data)
  },
  async (err) => {
    // 错误的特殊情况 => 401权限不足或token过期 => 拦截到登录
    const originalRequest = err.config
    //  判断是否是 401 并且不是已经重试过的请求
    if (err.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true
      // 控制 Token 刷新流程(防止多次刷新)
      if (!isRefreshing) {
        // 标记刷新状态
        isRefreshing = true
        try {
          const res = await refreshTokenService()
          const newToken = res.data.data.token
          // 重试请求
          onRefreshed(newToken)
        } catch {
          // 刷新失败
          message.error({ type: 'error', content: '登录已过期,请重新登录' })
          // 跳转登录
          if (window.location.pathname !== '/login') {
            history.push('/login')
          }
        } finally {
          isRefreshing = false
        }
      }
      // 把当前请求放入队列,等待 Token 刷新后再重发
      return new Promise((resolve) => {
        addRefreshSubscriber((newToken) => {
          originalRequest.headers['Authorization'] = `Bearer ${newToken}`
          resolve(instance(originalRequest))
        })
      })
    } else {
      // 错误的默认情况 =》 只给提示
      message.error({ type: 'error', content: err.response.data.message || '服务异常' })
    }
    return Promise.reject(err)
  }
)

export default instance


网站公告

今日签到

点亮在社区的每一天
去签到