React中自动刷新Token:网页登录的永动机

React, NodeJS, Express, Mongodb

在构建现代Web应用时,安全性和用户体验是我们不可或缺的考量。随着技术的发展,Token-based认证已成为保护用户会话的黄金标准。然而,随之而来的Token管理问题,尤其是如何有效处理Token过期问题,成为了开发者不得不面对的挑战。本篇博客将深入探讨如何在Web应用中实现自动刷新Token机制,旨在为读者提供一个既安全又不损害用户体验的解决方案。

引言与概述


在数字世界的每一个角落,安全性始终是我们永恒的追求。想象一下,每次网络请求都要求用户重新登录,这无疑会极大地影响用户体验,让人感到烦躁。为了解决这一问题,Token-based认证应运而生,它允许用户在一定时间内无需重复验证身份即可与服务器进行通信。然而,如何在保证安全的同时,处理Token过期并自动更新,却是一门值得深究的艺术

本篇博客将从Token的基本概念讲起,详细解释访问Token和刷新Token的区别及其各自的角色 。我们不仅会探讨为什么需要刷新Token ,更重要的是,如何在用户几乎感觉不到的情况下,自动地更新Token,从而保持用户会话的持续性。通过对这一过程的深入讲解,我们希望能够帮助开发者构建出更安全、更易用的Web应用。

背景与核心概念


如果你已经了解了Token的概念,以及明白了为什么需要Refresh Token,那么可以跳过这一章,直接看下一章节。

什么是Token

在Web应用中,Token主要用于用户认证和授权,可以理解为是一段加密好的信息。当我们登录成功的之后,服务器会给我们一个Token,我们以后只需要拿着这个Token换我们想要的资源就好了,这个Token相当于用户的身份证了。至于服务器如何制造这个身份证(Token)的,以什么统一的标准,那就可以去了解了解Json Web Token(JWT)了,JWT就是这个所谓的标准。

为什么需要Refresh Token

为了保证安全,我们一般会给Token加上一个过期时间(15分钟左右),这样哪怕Token真的被泄露了,攻击者也只能在有限的时间内使用这个Token。

可是这样的话,另一个问题又来了,我总不能让用户老师每隔15分钟就需要重新登录一次吧,太麻烦了,用户要烦了(试想你每隔一周就需要亲自跑去公安局重新办个身份证?)。所以我们需要打造一个机制,让程序能够自动刷新我们的Token。

那我们可以尝试这么做:

我们可以把Token分为两种:访问令牌(Access Tokens刷新令牌(Refresh Tokens 。访问令牌就是那个有效期比较短的,15分钟寿命的Token,可以用于访问受保护的资源;而刷新令牌我们可以给他一个更长的有效期,比如一周,用于在访问令牌过期后获取新的访问令牌,而无需用户重新登录。

这样一旦程序检测到访问令牌过期了,就可以让程序自己拿着刷新令牌去和服务器说,我还是我本人,你还是把新的访问令牌给我吧,然后就可以继续找服务器要资源了。

这有点像护照和签证的关系:登录时,服务器提供的短期签证(Access Token)允许我们自由访问资源。一旦签证过期,我们可以通过刷新令牌(Refresh Token),像自动续签一样,延长访问权限。

实现自动刷新Token


服务端:处理刷新令牌请求

创建Access TokenRefresh Token

当用户成功登录后,服务端需要生成两种令牌:一个是用于访问保护资源的Access Token,另一个是用于在Access Token过期后获取新的Access Token的Refresh Token。这两种令牌通常使用JSON Web Tokens (JWT)技术来创建。

js 复制代码
const jwt = require('jsonwebtoken');

// Access token expires in 15 minutes
const accessToken = jwt.sign({ userId: user._id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });

// Refresh token expires in 7 days
const refreshToken = jwt.sign({ userId: user._id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); 

使用JWT创建了两个Token之后,我们要把他们给两个地方:

  1. refreshToken存到我们自己的数据库里,以用来后续的验证。只有客户端发来的refreshToken和我们所储存的refreshToken一致时,我们才会允许返回一个新的accessToken
  2. 将此时创建好的accessTokenrefreshToken返回给客户端,因为用户已经登录成功了,需要这两个token来后续和服务端沟通。
js 复制代码
// Save refreshToken with user
// 这里我用的`mongodb`,你需要根据自己的配置更改语法
// 用一个array来储存refreshToken,因为可能会出现用户有多个登录端口的情况
user.refreshTokens.push({ token: refreshToken }); 
await user.save();

// Resonse with the new accessToken and refreshToken
res.status(200).send({ accessToken, refreshToken });

验证令牌

接收refreshToken

当客户端发起刷新访问令牌的请求时,它应该在请求体中包含刷新令牌。服务端首先需要从请求中提取这个令牌。

js 复制代码
const { refreshToken } = req.body; 
if (!refreshToken) { 
    return res.status(401).json({ message: "Refresh token is required" }); 
}

验证accessTokenrefreshToken的合法性

在后端,我们接收带有accessToken的请求时,都需要在正式处理请求之前,检验其合法性,有没有被篡改,有没有过期。如果accessToken过期了,我们也需要到时候把指定的错误代码传回客户端,这样客户端就知道要重新用refreshToken自动刷新一下accessToken了。这样的需求,在每个需要验证身份的请求前验证accessToken,这像不像要让我写个中间件?

js 复制代码
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.ACCESS_TOKEN_SECRET;

const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Format: "Bearer TOKEN"

  if (token == null) {
    return res.status(401).json({ message: "No token provided" });
  }

  jwt.verify(token, JWT_SECRET, (err, user) => {
    if (err) {
      // Check for token expiration specific error
      if (err.name === 'TokenExpiredError') {
        // Return 409 as the status code when the token expired
        // Client can use the same status code to check whether the token expired
        return res.status(409).json({ message: "Token expired" });
      } else {
        return res.status(403).json({ message: "Token is not valid" });
      }
    }

    // If the token is verified successfully, attach the user info to the request object
    req.user = user;
    next();
  });
};

module.exports = authenticateToken;

于是我在之后请求的时候就可以直接用了

js 复制代码
const authenticateToken = require('./middleware/authenticateToken');
// ...
app.get('/api/userInfo', authenticateToken, async (req, res) => {
    // ...
})

当然,对于refreshToken,在我们使用它来换取新的token时,我们也需要使用与创建刷新令牌时相同的密钥(process.env.REFRESH_TOKEN_SECRET)提前验证一下它的合法性。不过要多的一步是,我们还需要检查刷新令牌是否和我们数据库里储存的是有匹配的。

js 复制代码
app.post('/api/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(401).json({ message: "Refresh Token is required" });
  }

  try {
    const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    const user = await User.findById(payload.userId);

    if (!user) {
      return res.status(403).json({ message: "User not found" });
    }

    // Find the index of the token that matches the incoming refreshToken
    const tokenIndex = user.refreshTokens.findIndex(token => {
      return token.token === refreshToken
    });

    if (tokenIndex === -1) { // Token not found
      return res.status(403).json({ message: "Refresh Token is not valid" });
    }

    // ...
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      return res.status(403).json({ message: "Refresh Token has expired" });
    } else {
      console.error('Token Refresh Error:', error);
      return res.status(403).json({ message: "Invalid Refresh Token" });
    }
  }
});

发放新的Access Token

js 复制代码
    // Issue a new access token
    const accessToken = jwt.sign({ userId: user._id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    
    res.status(200).send({ accessToken }); // Response with the new accessToken

刷新令牌旋转:发放新的Refresh Token

为了增加安全性,一旦使用刷新令牌请求成功,应立即撤销该刷新令牌,并为用户生成一个新的刷新令牌。这种做法被称为刷新令牌的旋转,有助于防止刷新令牌被盗用。

js 复制代码
    // Issue a new refresh token
    const newRefreshToken = jwt.sign({ userId: user._id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); // Expires in 7 days

    // Replace the old refresh token with the new one at the same index
    user.refreshTokens[tokenIndex] = { token: newRefreshToken };

    await user.save(); // Save the updated user document
    res.status(200).send({ accessToken, refreshToken }); // Response with the new accessToken and refreshToken

客户端:自动刷新令牌

请求拦截

为了让我们可以在请求的过程中,自动截取到409的响应状态码,从而对过期的token发起刷新的请求,我们可以对原生的fetch方法做一个封装(authFetch),这样我们之后就可以直接使用authFetch为我们无感自动刷新accessToken。当然如果你用axios的话,也可以使用axios.interceptors去在axios请求中截取response

js 复制代码
async function authFetch(url, options) {
  let response = await fetch(url, options);

  if (response.ok) {
      return response;
  } else if (response.status === 409) { // Now, the server told us the access token expired
      // TODO: Create a refresh() function to get the new token
      return refresh().then(newToken => {
        const authOptions = {
          ...options,
          headers: {
            ...options.headers,
            'Authorization': `Bearer ${newToken}`, // Update the authorization header
          },
        };
        return fetch(url, authOptions); // Try to request again with the new token
      }).catch(err => {
        throw err; // Rethrow to be caught by the caller
      })
  } else {
      throw new Error('Failed to fetch');
  }
}

发送刷新令牌请求

这里,我们可以写一个refresh()方法,来发送我们刷新令牌的请求。

js 复制代码
const API_BASE_URL = 'http://localhost:8080';
async function refresh() {
  // Assuming the refresh token is stored in localStorage
  const refreshToken = localStorage.getItem('refreshToken');

  try {
    const response = await fetch(`${API_BASE_URL}/api/refresh`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ refreshToken }),
    });

    const data = await response.json();
    if (!response.ok) throw new Error(data.message || 'Could not refresh token');

    // Update localStorage with the new tokens
    localStorage.setItem('accessToken', data.accessToken);
    localStorage.setItem('refreshToken', data.refreshToken);

    return data.accessToken;
  } catch (error) {
    console.error('Refresh Token Error:', error);
    throw error;
  }
}

这样,通过refreshauthToken,我们就已经基本实现了自动刷新token。比如我们需要带着token去获取用户信息时,我们可以直接使用我们写好的authFetch

js 复制代码
const getUserInfo = async () => {
  try {
    const url = `${API_BASE_URL}/api/userInfo`;
    const accessToken = getAuthToken('accessToken');

    const options = {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${accessToken}`
      },
    };

    const response = await authFetch(url, options);

    if (!response.ok) {
      const error = new Error('Failed to fetch user info');
      error.info = await response.json();
      error.status = response.status;
      throw error;
    }

    return response.json(); // Return the user info
  } catch (error) {
    console.error('Error fetching user info:', error);
    throw error; // Rethrow the error so it can be handled by the caller
  }
};

避免重复的刷新令牌请求

在实现自动刷新Token机制时,特别是在并发请求的场景下,避免重复发送刷新令牌的请求是非常重要的。这不仅可以减少服务器的负担,还能避免因重复刷新而导致的潜在安全问题。为实现这个需求,我们可以采用下方的策略:

在尝试刷新令牌时,使用一个锁(例如,一个布尔变量)来标记正在进行的刷新操作。如果有请求在刷新操作进行时到达,这些请求将会等待刷新操作完成后再继续,而不是自己也尝试去刷新令牌。

当刷新令牌的请求正在进行时,我们需要对后续的请求进行缓存,直到刷新令牌的操作完成。一旦新的访问令牌可用,就使用它来重新发起被缓存的请求。

说人话就是:

有人已经在请求刷新token了,那就别掺合,一边儿排队。等有了新的token,大家一起用。

js 复制代码
let isRefreshing = false;
let requests = [];
async function authFetch(url, options) {
  let response = await fetch(url, options);

  if (response.ok) {
    return response;
  } else if (response.status === 409) {
    if (!isRefreshing) {
      isRefreshing = true;
      return refresh().then(newToken => {
        const authOptions = {
          ...options,
          headers: {
            ...options.headers,
            'Authorization': `Bearer ${newToken}`, // Update the authorization header
          },
        };
        return fetch(url, authOptions);
      }).catch(err => {
        // TODO: need a processQueue function to process the requests in list
        processQueue(err, null); // Process the queue with error
        throw err; // Rethrow to be caught by the caller
      }).finally(() => {
        isRefreshing = false; // Reset the refreshing flag
      });
    } else {
      // If a refresh is already in progress, queue this request
      return new Promise((resolve, reject) => {
        requests.push({
          resolve: () => resolve(authFetch(url, options)),
          reject: () => reject,
        });
      });
    }
  } else {
    throw new Error('Failed to fetch');
  }
}
js 复制代码
const processQueue = (error, token = null) => {
  requests.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  requests = [];
}

至此,我们就可以通过上述方法来实现一个自动刷新token的web了。

结语


在本文中,我们深入探讨了自动刷新Token机制的实现原理及其在现代Web应用中的重要性。从服务端的Token生成与验证,到客户端的请求拦截和令牌刷新逻辑,我们逐步揭示了构建安全、高效且用户友好的认证系统所需的关键技术要素。

你会了啥?

  1. 理解Token的角色:我们学习了访问令牌(Access Token)和刷新令牌(Refresh Token)在用户认证流程中的作用,以及它们如何共同工作以维持用户会话的安全与持久性。
  2. 安全性与用户体验的平衡:通过自动刷新Token机制,我们探索了如何在增强安全措施(例如,限制访问令牌的有效期)和保持良好用户体验(避免频繁重新登录)之间找到平衡点。
  3. 技术实现的深入理解:通过对服务端验证刷新令牌、客户端实现请求拦截和处理令牌刷新的逐步分析,我们了解了在实际应用开发中如何具体实施自动刷新Token机制。
相关推荐
GIS程序媛—椰子16 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00122 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端25 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟100929 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤439139 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt