Authentication认证机制在现代Web开发中的实践与踩坑总结

嘉蕊 ☘︎ 框架 阅读 2,613
赞 18 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

最近接了个B端项目,需要做用户权限管理,说实话刚开始我是有点抗拒的。以前做过几个项目,认证这一块总是各种麻烦,JWT、Session、OAuth2这些概念混在一起,每次都要重新梳理一遍。不过这次客户要求还挺明确:支持多设备登录、有一定安全级别、最好能快速开发。

Authentication认证机制在现代Web开发中的实践与踩坑总结

考虑了几种方案,最后还是选择了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集成等,有机会再单独写篇文章聊聊。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论