Skip to content

Node系列 · Express:登录认证客户端

客户端怎么配合 JWT 鉴权?本章讲 SPA(Vue / React)的标准做法——登录拿 token、axios 拦截器自动加 header、路由守卫、刷新 token 机制。

一、SPA 鉴权流程

二、connect-history-api-fallback 处理 SPA 路由

SPA 路由(Vue Router / React Router)的 history 模式下,所有路径都返回 index.html。需要服务端 fallback:

bash
npm install connect-history-api-fallback
javascript
const history = require('connect-history-api-fallback');

// 静态文件目录
app.use(express.static('dist'));

// history 模式 fallback:所有非 API 路径都返回 index.html
app.use(history({
  rewrites: [
    { from: /^\/api\/.*$/, to: '/index.html' },  // 不重写 API
  ],
}));
text
GET /                    → dist/index.html
GET /login               → dist/index.html
GET /profile/123         → dist/index.html
GET /api/users           → 真实 API
GET /api/auth/login      → 真实 API

如果不配置这个中间件:

bash
$ curl http://localhost:3000/profile
Cannot GET /profile

三、登录页

vue
<template>
  <form @submit.prevent="handleLogin">
    <input v-model="email" placeholder="邮箱" />
    <input v-model="password" type="password" placeholder="密码" />
    <button type="submit">登录</button>
  </form>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const email = ref('');
const password = ref('');

async function handleLogin() {
  const { data } = await axios.post('/api/auth/login', {
    email: email.value,
    password: password.value,
  });

  // 存 token 和用户信息
  localStorage.setItem('token', data.token);
  localStorage.setItem('user', JSON.stringify(data.user));

  // 跳转首页
  router.push('/');
}
</script>

四、token 存哪里

存储优点缺点
localStorage简单、跨域友好XSS 可盗
sessionStorage关闭标签页清空XSS 可盗
HttpOnly Cookie防 XSS跨域受 CORS 限制

TIP

一般项目用 localStorage——简单够用。安全要求高的项目用 HttpOnly Cookie(需要后端配合)。

五、axios 拦截器自动加 token

每次请求手动加 Authorization 头太烦——用 axios 拦截器统一处理:

javascript
import axios from 'axios';

// 请求拦截器:自动加 token
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:处理 401 自动跳登录页
axios.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      localStorage.removeItem('user');
      router.push('/login');
    }
    return Promise.reject(error);
  }
);

export default axios;

之后所有 axios.get('/api/xxx') 自动带 token,401 自动跳登录页。

六、Vue Router 路由守卫

javascript
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: Login, meta: { public: true } },
    { path: '/', component: Home, meta: { requiresAuth: true } },
    { path: '/profile', component: Profile, meta: { requiresAuth: true } },
  ],
});

// 全局守卫
router.beforeEach((to) => {
  const token = localStorage.getItem('token');

  // 受保护路由没 token → 跳登录
  if (to.meta.requiresAuth && !token) {
    return { path: '/login', query: { redirect: to.fullPath } };
  }

  // 已登录访问 /login → 跳首页
  if (to.path === '/login' && token) {
    return { path: '/' };
  }
});

七、刷新 token 机制

access token 过期后自动用 refresh token 换新的:

javascript
let isRefreshing = false;
let pendingRequests = [];

axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 正在刷新,挂起后续请求
        return new Promise((resolve) => {
          pendingRequests.push((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            resolve(axios(originalRequest));
          });
        });
      }

      isRefreshing = true;
      originalRequest._retry = true;

      try {
        const refreshToken = localStorage.getItem('refreshToken');
        const { data } = await axios.post('/api/auth/refresh', { refreshToken });

        localStorage.setItem('token', data.accessToken);

        // 重发原请求 + 队列里的请求
        axios.defaults.headers.common.Authorization = `Bearer ${data.accessToken}`;
        pendingRequests.forEach((cb) => cb(data.accessToken));
        pendingRequests = [];

        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
        return axios(originalRequest);
      } catch (refreshError) {
        // refresh token 也过期了 → 跳登录
        localStorage.clear();
        router.push('/login');
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

八、SSR 与 SPA 的取舍

场景鉴权方式
SPA(Vue / React)token 存 localStorage + Authorization header
SSR(Nuxt / Next)session Cookie(HttpOnly,浏览器自动带)
移动端token 存 SecureStorage + Authorization header
微服务API Gateway 统一鉴权 + token 透传

九、最佳实践

场景推荐
SPA history 路由connect-history-api-fallback
token 存储API 用 localStorage;高安全用 HttpOnly Cookie
自动加 tokenaxios 请求拦截器
401 处理axios 响应拦截器:清 token + 跳登录
路由守卫beforeEach 检查 meta.requiresAuth
长会话双 token(access 短 + refresh 长)
退出清 localStorage + 跳登录页

十、小结

  • SPA history 模式必须配 connect-history-api-fallback,否则刷新页面 404
  • token 存 localStorage;axios 拦截器自动加 Authorization: Bearer header
  • 401 响应拦截器:清 token + 跳登录页
  • Vue Router 守卫 beforeEach 检查 meta.requiresAuth
  • Refresh Token 机制:access 过期后自动用 refresh 换新,期间挂起的请求一并重发
  • API 用 localStorage + header;SSR 用 session Cookie;移动端用 SecureStorage