双 Token 无感刷新机制在前后端分离架构中实现
在前后端分离的架构中,双 Token 无感刷新是一种常见的身份验证机制,用于在 Access Token 过期时,通过 Refresh Token 自动获取新的 Access Token,从而避免用户频繁登录。
1. 双 Token 无感刷新的核心流程
1.1 核心流程
-
用户登录:
- 前端发送用户名和密码到后端。
- 后端验证成功后,返回
Access Token
和Refresh Token
。 - 前端将这两个 Token 存储在本地(如
localStorage
或Cookie
)。
-
Access Token 过期:
- 前端发起请求时,携带
Access Token
。 - 如果
Access Token
过期,后端返回401 Unauthorized
错误。
- 前端发起请求时,携带
-
自动刷新 Token:
- 前端检测到
401
错误后,使用Refresh Token
请求新的Access Token
。 - 后端验证
Refresh Token
的有效性,如果有效,返回新的Access Token
。 - 前端更新本地存储的
Access Token
,并重试之前的请求。
- 前端检测到
-
Refresh Token 过期:
- 如果
Refresh Token
也过期,后端返回401
错误。 - 前端清除本地存储的 Token,并跳转到登录页面。
- 如果
2. 前端实现(React + TypeScript)
2.1 配置 Axios 拦截器
Axios 拦截器用于在请求发送前和响应返回后执行逻辑,是实现无感刷新的核心。
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
// 创建 Axios 实例
const api: AxiosInstance = axios.create({
baseURL: 'https://your-api-url.com',
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器:在请求头中添加 Access Token
api.interceptors.request.use(
(config: AxiosRequestConfig) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
// 响应拦截器:处理 Access Token 过期
api.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config;
// 检测到 401 错误且未重试过
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 标记请求已重试
try {
// 使用 Refresh Token 获取新的 Access Token
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post('https://your-api-url.com/refresh-token', { refreshToken });
const { accessToken } = response.data;
// 更新本地存储的 Access Token
localStorage.setItem('accessToken', accessToken);
api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// 重试原始请求
return api(originalRequest);
} catch (refreshError) {
// 刷新 Token 失败,清除本地存储并跳转到登录页
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;
2.2 使用 React Context 管理 Token 状态
通过 React Context 管理 Token 的状态,方便在组件中共享和更新。
import React, { createContext, useContext, useState, useEffect } from 'react';
interface AuthContextType {
accessToken: string | null;
refreshToken: string | null;
setTokens: (accessToken: string, refreshToken: string) => void;
clearTokens: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('accessToken'));
const [refreshToken, setRefreshToken] = useState<string | null>(localStorage.getItem('refreshToken'));
const setTokens = (accessToken: string, refreshToken: string) => {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setAccessToken(accessToken);
setRefreshToken(refreshToken);
};
const clearTokens = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setAccessToken(null);
setRefreshToken(null);
};
return (
<AuthContext.Provider value={{ accessToken, refreshToken, setTokens, clearTokens }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
2.3 在组件中使用
在需要身份验证的组件中,使用 useAuth
Hook 获取和更新 Token。
import React from 'react';
import { useAuth } from './AuthContext';
import api from './api';
const ProtectedComponent: React.FC = () => {
const { accessToken, setTokens, clearTokens } = useAuth();
const fetchData = async () => {
try {
const response = await api.get('/protected-resource');
console.log(response.data);
} catch (error) {
console.error('Failed to fetch data', error);
}
};
return (
<div>
<button onClick={fetchData}>Fetch Data</button>
<button onClick={clearTokens}>Logout</button>
</div>
);
};
export default ProtectedComponent;
3. 后端实现(Node.js + Express)
3.1 登录接口
登录成功后返回 Access Token
和 Refresh Token
。
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const SECRET_KEY = 'your-secret-key';
const REFRESH_SECRET_KEY = 'your-refresh-secret-key';
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 模拟用户验证
if (username === 'admin' && password === 'password') {
const accessToken = jwt.sign({ username }, SECRET_KEY, { expiresIn: '15m' });
const refreshToken = jwt.sign({ username }, REFRESH_SECRET_KEY, { expiresIn: '7d' });
res.json({ accessToken, refreshToken });
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
});
3.2 刷新 Token 接口
验证 Refresh Token
并返回新的 Access Token
。
app.post('/refresh-token', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token is required' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET_KEY);
const accessToken = jwt.sign({ username: decoded.username }, SECRET_KEY, { expiresIn: '15m' });
res.json({ accessToken });
} catch (error) {
res.status(401).json({ message: 'Invalid refresh token' });
}
});
4. 总结
4.1 核心优势
- 无感刷新:用户无需手动登录即可获取新的 Access Token。
- 安全性:Access Token 有效期短,Refresh Token 有效期长但仅用于刷新 Access Token。
- 用户体验:减少用户因 Token 过期而需要频繁登录的情况。
4.2 注意事项
- 并发请求:当多个请求同时触发 Token 刷新时,需要确保只有一个刷新请求被发送。
- 安全性:Refresh Token 应存储在安全的地方(如 HttpOnly Cookie)。
- 错误处理:在 Token 刷新失败时,应清除本地存储并跳转到登录页面。
通过以上实现,双 Token 无感刷新机制可以在前后端分离的项目中有效提升用户体验和安全性。