前端JWT认证与无感刷新Token实现详解
1. 引言
在现代Web应用中,JWT (JSON Web Token) 认证已成为无状态身份验证的主流选择。相比传统的基于会话的认证方式,JWT提供了更高的扩展性和灵活性。本文将深入探讨如何在前端实现JWT认证,并通过拦截器实现token的无感刷新,同时结合WebSocket连接的认证机制,构建一个完整的前端身份验证体系。
2. JWT认证原理
JWT是一种开放标准,它使用紧凑且自包含的方式,在各方之间安全地传输信息。
graph LR A[客户端] -->|1. 提交用户名密码| B[服务器] B -->|2. 验证凭据| B B -->|3. 生成JWT| B B -->|4. 返回JWT| A A -->|5. 存储JWT| A A -->|6. 后续请求携带JWT| B B -->|7. 验证JWT签名| B B -->|8. 返回受保护资源| A
JWT由三部分组成:
- Header: 包含令牌类型和使用的算法
- Payload: 包含声明(用户ID、权限等)
- Signature: 用于验证token未被篡改
2.1 JWT结构示例
xxxxx.yyyyy.zzzzz
xxxxx
: Base64Url编码的Headeryyyyy
: Base64Url编码的Payloadzzzzz
: 使用Header中指定算法和密钥计算的签名
3. 前端登录实现与JWT存储
前端登录页面需要收集用户凭据,发送到服务器进行验证,并处理返回的JWT。
3.1 登录API封装
typescript
// 定义登录接口返回的数据类型
export interface LoginResponse {
accessToken?: string; // 可选字段
token?: string; // 可选字段,某些后端API可能使用token替代accessToken
refreshToken?: string; // 可选字段
user?: {
id: string;
username: string;
avatar: string;
status: string;
};
}
/**
* 登录API
* @param username 用户名
* @param password 密码
* @returns 返回包含 token 和用户信息的响应
*/
export async function loginAPI(username: string, password: string) {
return request<ResponseData<LoginResponse>>('/api/users/login', {
method: 'POST',
body: JSON.stringify({ username, password }), // 将 username 和 password 放入 body 中
headers: {
'Content-Type': 'application/json', // 设置请求头,指明请求体的格式
},
});
}
3.2 登录逻辑与JWT存储
typescript
const handleLogin = async () => {
if (!username || !password) {
setErrorMsg('请输入用户名和密码');
return;
}
try {
const response = await loginAPI(username, password);
console.log('登录响应:', response);
// 处理直接返回token的情况和通过data属性返回token的情况
const responseData = response.data || response;
if (responseData) {
// 存储 token 和 refreshToken
if (responseData.accessToken) {
localStorage.setItem('token', responseData.accessToken);
} else if (responseData.token) {
localStorage.setItem('token', responseData.token);
}
// 存储 refreshToken
if (responseData.refreshToken) {
localStorage.setItem('refreshToken', responseData.refreshToken);
}
// 存储用户 ID
if (responseData.user && responseData.user.id) {
localStorage.setItem('id', responseData.user.id);
}
window.location.href = '/chat';
} else {
setErrorMsg('登录失败,未返回 token');
}
} catch (error: any) {
console.error('登录请求失败:', error);
// 错误处理逻辑...
}
};
3.3 JWT存储安全性思考
将JWT存储在localStorage
中提供了持久化存储,但存在XSS攻击风险。考虑以下替代方案:
- HttpOnly Cookie: 更安全,但需要后端配合设置适当的Cookie策略
- 内存存储 + 刷新机制: 在内存中保存token,页面刷新时通过refresh_token重新获取
- 加密存储: 在localStorage中存储前先加密,但仍不能完全防范XSS
graph TD A[JWT存储方式] --> B[localStorage/sessionStorage] A --> C[HttpOnly Cookie] A --> D[内存存储] A --> E[加密存储] B --> F[优点: 方便实现, 持久存储] B --> G[缺点: 易受XSS攻击] C --> H[优点: 抵抗XSS攻击] C --> I[缺点: 需适当的CORS和Cookie策略] D --> J[优点: 抵抗XSS, 页面关闭即失效] D --> K[缺点: 刷新页面时需重新登录] E --> L[优点: 增加攻击难度] E --> M[缺点: 仍不能完全防范XSS]
4. Axios请求拦截器
请求拦截器确保每个API请求都带有最新的JWT,实现认证信息的自动化添加。
typescript
// 请求拦截器
// 在发送请求前对请求配置进行处理
request.interceptors.request.use((url, options) => {
// 从本地存储中获取token
const token = localStorage.getItem('token');
// 设置Authorization头部,如果token存在则使用Bearer模式
const authHeader = { Authorization: token ? `Bearer ${token}` : '' };
// 返回修改后的请求配置
return {
url,
options: {
...options,
headers: {
...options.headers,
...authHeader, // 将Authorization头部添加到请求头中
},
},
};
});
5. Token无感刷新实现
无感刷新是前端JWT认证中的关键环节,当access_token过期时,系统使用refresh_token自动更新token,无需用户重新登录。
5.1 刷新Token方法
typescript
// 添加刷新 token 的接口
export async function refreshToken(refreshTokenStr: string) {
try {
const response = await fetch(`${process.env.API_URL || 'http://localhost:3006'}/api/users/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: refreshTokenStr }),
});
if (!response.ok) {
throw new Error('刷新token失败');
}
const data = await response.json();
console.log('刷新token响应:', data);
return data;
} catch (error) {
console.error('刷新token出错:', error);
throw error;
}
}
5.2 响应拦截器中实现无感刷新
核心是在响应拦截器中捕获401错误,然后触发token刷新流程:
typescript
// 是否正在刷新 token,用于响应拦截器
let isRefreshing = false;
// 等待 token 刷新的请求队列
let refreshSubscribers: ((token: string) => void)[] = [];
// 将请求添加到队列
// cb 是回调函数,用于在 token 刷新后执行队列中的请求
const subscribeTokenRefresh = (cb: (token: string) => void) => {
refreshSubscribers.push(cb);
};
// 刷新 token 后执行队列中的请求
const onTokenRefreshed = (token: string) => {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
};
/**
* 响应拦截器
* 在接收到响应后对响应数据进行处理
*/
request.interceptors.response.use(async (response) => {
// 克隆响应以避免多次读取 body
const res = response.clone();
// 检查响应状态码
if (res.status === 401) {
console.log('检测到401错误,尝试刷新token');
// 获取原始请求的URL和选项
const url = response.url;
const options = {
method: response.request?.method || 'GET',
headers: {
...Object.fromEntries(response.clone().headers.entries())
},
body: response.request?.body
};
// 如果是刷新 token 的请求失败,则清除 token 并跳转到登录页
if (url.includes('/refresh-token')) {
console.log('刷新token请求失败');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject({ message: '刷新 token 失败,请重新登录' });
}
// 获取 refreshToken
const refreshTokenStr = localStorage.getItem('refreshToken');
if (!refreshTokenStr) {
console.log('没有找到refreshToken,跳转到登录页');
localStorage.removeItem('token');
window.location.href = '/login';
return Promise.reject({ message: '未找到刷新令牌,请重新登录' });
}
// 如果当前没有在刷新 token,则开始刷新
if (!isRefreshing) {
isRefreshing = true;
console.log('开始刷新token');
try {
// 调用刷新 token 的接口
const refreshRes = await refreshToken(refreshTokenStr);
console.log('刷新token响应:', refreshRes);
if (refreshRes && refreshRes.accessToken) {
console.log('刷新token成功:', refreshRes);
// 更新本地存储的 token
const newToken = refreshRes.accessToken;
localStorage.setItem('token', newToken);
console.log('已更新token:', newToken);
// 通知所有等待的请求
onTokenRefreshed(newToken);
// 重置刷新状态
isRefreshing = false;
// 使用新 token 重新发送原始请求
console.log('使用新token重新发送请求');
const newOptions = {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
};
// 返回重新发送的请求
return request(url, newOptions);
} else {
// 刷新失败,清除 token 并跳转到登录页
console.error('刷新token失败,响应不包含新token');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
isRefreshing = false;
return Promise.reject({ message: '刷新 token 失败,请重新登录' });
}
} catch (error) {
// 刷新出错,清除 token 并跳转到登录页
console.error('刷新token过程中出错:', error);
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
isRefreshing = false;
return Promise.reject(error);
}
} else {
// 如果已经在刷新 token,则将请求加入队列
console.log('已有刷新token请求在进行中,将当前请求加入队列');
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
const newOptions = {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
};
resolve(request(url, newOptions));
});
});
}
}
// 处理正常响应...
return response;
});
5.3 Token无感刷新流程
sequenceDiagram participant A as 客户端 participant B as 请求拦截器 participant C as API服务器 participant D as 响应拦截器 participant E as Token刷新服务 A->>B: 发起API请求 B->>B: 添加Authorization头 B->>C: 发送请求 C->>D: 返回401(token过期) rect rgb(191, 223, 255) note over D: Token刷新流程开始 alt 首次遇到401 D->>E: 发送刷新token请求 E->>D: 返回新token D->>B: 更新本地token D->>D: 通知等待队列 D->>C: 使用新token重发原请求 C->>A: 返回API响应 else 已有刷新请求进行中 D->>D: 将请求加入等待队列 D->>D: 等待token刷新完成 D->>C: 收到新token后重发请求 C->>A: 返回API响应 end end
6. WebSocket连接中的JWT认证
WebSocket建立长连接时同样需要进行身份认证,确保只有授权用户才能建立连接和收发消息。
typescript
// 创建WebSocket连接
this.socket = io('http://localhost:3006', {
transports: ['websocket'], // 使用websocket传输
autoConnect: true, // 自动连接
path: '/socket.io', // socket.io的路径
auth: {
token: this.token // 认证token
},
query: {
userId: this.userId // 用户ID
}
});
6.1 WebSocket认证与心跳机制
WebSocket连接后,通过心跳机制维持连接活跃,同时心跳消息中也携带了用户身份信息:
typescript
// 创建心跳消息
const message = HeartbeatMessage.create({
userId: this.userId, // 用户ID
timestamp: Date.now(), // 当前时间戳
type: type === 'PING' ? 0 : 2, // 心跳类型
retryCount: retryCount // 重试计数
});
// 编码消息
const buffer = HeartbeatMessage.encode(message).finish();
// 发送心跳
this.socket.emit('heartbeat', buffer);
7. JWT安全策略与最佳实践
7.1 多层次安全策略
mindmap root((JWT安全策略)) Token管理 短期有效的access_token 长期有效的refresh_token 实现token轮换 存储安全 敏感信息不存入Payload 考虑使用HttpOnly Cookie 加密存储refresh_token 传输安全 使用HTTPS 仅在必要时传输token token验证 验证签名 检查过期时间 验证颁发者 权限控制 基于角色的访问控制 细粒度权限设计 最小权限原则
7.2 最佳实践
- Access Token短期有效: 通常设置为15-30分钟
- Refresh Token轮换: 每次刷新后生成新的refresh token
- 合理的错误处理: 提供友好的用户体验
- 防止并发刷新: 使用锁或标志位防止多个请求同时触发刷新
- 监控与日志: 记录token使用和刷新情况,便于排查问题
8. JWT分析与解码
前端可以解析JWT,查看其中包含的信息,用于调试和开发:
typescript
// 首先,我们检查是否存在 token
if (token) {
try {
// 将 token 按照 '.' 分割,获取第二部分,这部分通常是 JWT 的负载部分
const base64Url = token.split('.')[1];
// 将 base64Url 中的字符进行替换,以符合 base64 的标准格式
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// 使用 atob 函数将 base64 字符串解码为原始字符串
// 然后使用 decodeURIComponent 处理字符串中的编码
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
// 将解码后的 JSON 字符串解析为对象
const payload = JSON.parse(jsonPayload);
// 输出 token 的过期时间
console.log('Token 过期时间:', new Date(payload.exp * 1000));
// 输出当前时间
console.log('当前时间:', new Date());
// 计算并输出 token 剩余的有效时间(以秒为单位)
console.log('剩余时间(秒):', payload.exp - Math.floor(Date.now() / 1000));
} catch (error) {
// 如果解析过程中发生错误,输出错误信息
console.error('解析 token 失败:', error);
}
}
9. 完整的JWT认证流程图
flowchart TD A[用户登录] --> B{验证成功?} B -->|是| C[服务器生成JWT] B -->|否| D[返回错误] C --> E[返回access_token和refresh_token] E --> F[本地存储tokens] F --> G[用户访问受保护资源] G --> H[请求拦截器添加Authorization头] H --> I[发送API请求] I --> J{响应状态} J -->|200成功| K[返回数据] J -->|401未授权| L{是否正在刷新token?} L -->|是| M[将请求加入队列] L -->|否| N[使用refresh_token获取新token] N --> O{刷新成功?} O -->|是| P[更新本地token] O -->|否| Q[清除tokens并跳转登录页] P --> R[重发原始请求] R --> K M --> S[等待token刷新完成] S --> R %% WebSocket认证流程 F --> T[初始化WebSocket连接] T --> U[连接时携带token] U --> V{WebSocket认证} V -->|成功| W[启动心跳机制] V -->|失败| X[WebSocket连接断开] X --> Q W --> Y[定期发送心跳] Y --> Z[保持连接活跃]
10. 总结与思考
通过本文,我们详细介绍了前端JWT认证的实现方式,包括登录认证、token存储、请求拦截、无感刷新和WebSocket认证。以下是一些关键的思考点:
10.1 权衡与取舍
graph TD subgraph 高安全性/复杂实现 D["多因素认证 (高安全/最复杂)"] E["OAuth 2.0 + PKCE (高安全/复杂)"] F["JWT + 双token + 无感刷新 (较高安全/中等复杂)"] end subgraph 中等安全性/中等复杂度 G["JWT + HttpOnly Cookie (中高安全/中等复杂)"] H["基于Cookie的会话认证 (中等安全/中等简单)"] end subgraph 低安全性/简单实现 I["JWT + localStorage (低安全/简单实现)"] end
10.2 未来发展方向
- 统一身份认证标准: 如OAuth 2.1和FIDO2
- 零信任架构: 不再隐式信任网络内部的用户
- 去中心化身份: 基于区块链的自主身份识别
- 无密码认证: 生物识别和硬件token代替传统密码
10.3 开发建议
- 充分测试token刷新逻辑,特别是并发场景
- 开发环境和生产环境使用不同的token有效期策略
- 考虑添加token使用情况的分析功能
- 设计用户友好的会话过期处理流程