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-fallbackjavascript
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 |
| 自动加 token | axios 请求拦截器 |
| 401 处理 | axios 响应拦截器:清 token + 跳登录 |
| 路由守卫 | beforeEach 检查 meta.requiresAuth |
| 长会话 | 双 token(access 短 + refresh 长) |
| 退出 | 清 localStorage + 跳登录页 |
十、小结
- SPA history 模式必须配
connect-history-api-fallback,否则刷新页面 404 - token 存 localStorage;axios 拦截器自动加
Authorization: Bearerheader - 401 响应拦截器:清 token + 跳登录页
- Vue Router 守卫
beforeEach检查meta.requiresAuth - Refresh Token 机制:access 过期后自动用 refresh 换新,期间挂起的请求一并重发
- API 用 localStorage + header;SSR 用 session Cookie;移动端用 SecureStorage
