Authentication认证机制在现代Web开发中的实践与踩坑总结
项目初期的技术选型
最近接了个B端项目,需要做用户权限管理,说实话刚开始我是有点抗拒的。以前做过几个项目,认证这一块总是各种麻烦,JWT、Session、OAuth2这些概念混在一起,每次都要重新梳理一遍。不过这次客户要求还挺明确:支持多设备登录、有一定安全级别、最好能快速开发。
考虑了几种方案,最后还是选择了JWT + Refresh Token的组合。主要是考虑到现在大部分新项目都这么搞,团队成员也比较熟悉。而且相比传统的Session方案,JWT对前后端分离确实友好一些,不用维护服务器端的状态。
核心实现方案
具体实现上,我设计了一个比较标准的流程。前端主要负责Token的存储、拦截器处理,后端负责Token的生成和验证。这里贴一下前端的核心代码:
// auth.js
class AuthManager {
constructor() {
this.accessToken = localStorage.getItem('access_token');
this.refreshToken = localStorage.getItem('refresh_token');
this.isRefreshing = false;
this.failedQueue = [];
}
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
getAccessToken() {
return this.accessToken;
}
logout() {
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
async refreshToken() {
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
});
}
this.isRefreshing = true;
try {
const response = await fetch('https://jztheme.com/api/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: this.refreshToken
})
});
if (response.ok) {
const data = await response.json();
this.setTokens(data.access_token, data.refresh_token);
// 处理等待队列
this.failedQueue.forEach(({ resolve }) => {
resolve(this.accessToken);
});
return this.accessToken;
} else {
throw new Error('Refresh token failed');
}
} catch (error) {
this.logout();
window.location.href = '/login';
throw error;
} finally {
this.isRefreshing = false;
this.failedQueue = [];
}
}
}
const authManager = new AuthManager();
export default authManager;
然后是Axios拦截器的处理:
// axios-interceptor.js
import axios from 'axios';
import authManager from './auth';
axios.interceptors.request.use(
(config) => {
const token = authManager.getAccessToken();
if (token) {
config.headers.Authorization = Bearer ${token};
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await authManager.refreshToken();
originalRequest.headers.Authorization = Bearer ${newToken};
return axios(originalRequest);
} catch (refreshError) {
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
最大的坑:并发请求处理
这里踩的坑比想象中多。项目中期测试的时候发现一个问题:当同时发起多个请求,恰好这时候Token过期,就会出现所有请求都被重定向到登录页的情况。比如页面上同时有获取用户信息、获取统计数据、获取通知列表等多个接口,如果这时候Token过期,会触发多次刷新Token的请求。
开始没想到这个问题有多严重,后来测试环境一跑才发现,用户明明在正常操作,却莫名其妙被登出了。查了半天才发现是并发问题。后来调整了方案,就是上面代码里的failedQueue机制。当第一次请求发现Token过期时,设置isRefreshing标志,后面的所有请求都加入队列等待,直到Token刷新成功后统一处理。
还有一个细节,就是刷新Token失败后的处理。这里不能简单地清空所有Token,还要考虑用户体验。我设置了延时处理,给用户一个重新登录的机会,而不是立即跳转。
存储策略的选择
关于Token的存储,我纠结了好久。LocalStorage最方便,但是存在XSS风险;Cookie虽然相对安全一些,但又要考虑跨域问题。最后还是选择了LocalStorage,因为这个项目本身就是单页应用,跨域情况比较少,而且可以通过HttpOnly Cookie来存储敏感信息。
为了增加安全性,我还加了一层加密。不过说实话,这种做法也增加了复杂度,对于大部分项目来说可能有点过度设计了。
// 加密存储
function encryptToken(token) {
return btoa(encodeURIComponent(token));
}
function decryptToken(encryptedToken) {
return decodeURIComponent(atob(encryptedToken));
}
用户体验方面的优化
除了基本的功能实现,我还考虑了一些用户体验的问题。比如Token快过期前提前刷新,避免用户在操作过程中突然被要求重新登录。
// token过期检查
function checkTokenExpiry() {
if (!authManager.accessToken) return false;
try {
const payload = JSON.parse(atob(authManager.accessToken.split('.')[1]));
const expTime = payload.exp * 1000;
const now = Date.now();
// 提前5分钟刷新
if (expTime - now < 5 * 60 * 1000) {
return true;
}
} catch (e) {
return false;
}
return false;
}
另外还实现了静默刷新的功能,就是在页面加载时自动检查Token状态,这样用户打开页面就能保持登录状态。
最终的解决方案
经过几轮迭代,最终的方案还算稳定。测试阶段发现的问题基本都解决了,包括并发请求、Token过期处理、存储安全等。生产环境上线后运行了几个月,没有出现严重的认证相关问题。
不过说实话,这个方案也不是完美的。比如在移动端浏览器兼容性方面还有一些小问题,某些老旧浏览器对localStorage的支持不够稳定。但总体来说,这套认证体系满足了项目的基本需求。
回顾与反思
回头看整个开发过程,认证这块确实比我预想的要复杂。光是处理并发请求就花了我两天时间,更别说各种边界情况的处理。如果再来一次的话,我可能会选择现成的认证库,比如Auth0或者Firebase Auth,虽然会增加一些依赖,但稳定性确实更好。
另一个学到的经验是,认证逻辑一定要在项目初期就设计好,等到后期再改会很麻烦。特别是涉及到权限控制的地方,如果前期没规划好,后期改动的成本会很高。
这次项目让我对认证流程有了更深的理解,特别是JWT的使用场景和限制。虽然网上教程很多,但真正应用到生产环境中还是有很多细节需要注意的。
以上是我踩坑后的总结,希望对你有帮助。这个话题还有很多细节可以聊,比如权限粒度控制、SSO集成等,有机会再单独写篇文章聊聊。

暂无评论