主题
界面
主题
《Spring Security 认证与授权》 讲了服务端怎么"验 token";《Filter、Listener 与会话管理》 讲了三种方案的取舍。本节站在前端视角补最后一环:
token 拿到后 → 存在哪?怎么带?过期了怎么办?刷新页面还在吗?fetch 与拦截 401 吗?(《Ajax 与前后端交互》 的 5.2 节)✓登录态是"前后端契约"里最容易出安全事故的一环 ✗
| 事故 | 根因 |
|---|---|
| token 被 XSS 偷走 | 存在了 localStorage 且页面有 XSS ✗ |
| 登录后刷新页面又变未登录 | token 没持久化,或没在启动时恢复 ✓(这是 bug 不是安全) |
| token 永不过期 | 泄漏后无法止损 ✗✗ |
| 存储位置 | XSS 能偷吗 | CSRF 风险 | 刷新后还在 | 典型场景 |
|---|---|---|---|---|
localStorage | ✅ 能 ✗✗ | ❌ 无 ✓ | ✅ 在 | SPA(接受 XSS 风险的取舍)⚠️ |
| 内存(JS 变量) | ✅ 能(运行期) | ❌ 无 | ❌ 刷新即失 ✗ | 最安全(配 refresh token)✓ |
| Cookie(HttpOnly) | ❌ 不能 ✓✓ | ⚠️ 有(要防 CSRF) | ✅ 在 | 同域传统应用 ✓ |
★ 没有完美方案,只有取舍 ✓
| 派别 | 逻辑 |
|---|---|
| HttpOnly Cookie 派 | "XSS 偷不走会话" ✓——代价是要防 CSRF(《Filter、Listener 与会话管理》)⚠️ |
| localStorage 派 | "跨域/多端方便" ✓——代价是"有 XSS 就全丢" ⚠️ ——所以要把 XSS 防线做牢(《JSP 与 JSTL(技术演进理解)》 的默认转义)✓ |
实务建议:
| 场景 | 推荐 |
|---|---|
| 同域单体 | HttpOnly Cookie + Session(《Filter、Listener 与会话管理》)✓ |
| 跨域 SPA | 内存 access token(短)+ HttpOnly refresh token(长) ★★ |
| 移动端 | Secure 存储(系统级 keystore)✓ |
access token :短命(15 分钟),放内存 ★,每次请求携带
refresh token:长命(7 天),放 HttpOnly Cookie ★,仅用于"换新 access token"
access 过期 → 用 refresh 换新的 access → 用户无感 ✓
refresh 失效 → 真正重新登录 ✓为什么这么设计:
| 设计 | 收益 |
|---|---|
| access 放内存 | 刷新页面就没了 → 即使被 XSS 偷走,最多用 15 分钟 ✓ |
| refresh 放 HttpOnly Cookie | JS 摸不到 ✓ + 后端可主动吊销 ✓ |
| access 短命 | "改密码/封号"的生效延迟最多 15 分钟 ✓ |
★ 这就是"无状态的便利"与"可吊销的安全"的组合拳 ✓
// auth.js —— 登录态的统一封装
const tokens = { access: null };
export async function login(username, password) {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // refresh token 在 HttpOnly Cookie 里 ★
body: JSON.stringify({ username, password }),
});
if (!r.ok) throw new Error((await r.json()).message);
const body = await r.json();
tokens.access = body.token; // ★ access 只放内存
}
export async function api(path, options = {}) {
const r = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(tokens.access ? { Authorization: `Bearer ${tokens.access}` } : {}),
...options.headers,
},
});
if (r.status === 401 && tokens.access) {
// ★ access 过期:用 refresh(Cookie 自动带上)换新 token,再重试一次
const rr = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (rr.ok) {
tokens.access = (await rr.json()).token;
return api(path, options); // 重试原请求 ✓
}
tokens.access = null;
location.href = '/login'; // refresh 也失效 → 真正重新登录
}
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).message || `HTTP ${r.status}`);
return r.status === 204 ? null : r.json();
}闭环里的五个关键点 ★:
| # | 设计 | 为什么 |
|---|---|---|
| ① | access 放内存、refresh 放 HttpOnly Cookie | 双方的风险各自最小(5.1)✓ |
| ② | 401 时先尝试 refresh 再重试 | 用户无感续期 ✓ |
| ③ | refresh 也失效才跳登录 | 避免误踢 ✓ |
| ④ | 封装在 api() 里 | 业务代码不碰 token 细节 ✓(关注点分离) |
| ⑤ | credentials: 'include' | Cookie 自动带上(《Ajax 与前后端交互》)✓ |
"没登录就不让进某些页面"(前端的"权限检查"):
// Vue Router
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }; // ★ 记住"想去哪"
}
});
// 登录成功后:router.push(route.query.redirect ?? '/') ← 体验细节 ✓★ 守卫只是"体验层" ⚠️:
真正的权限控制必须在后端(《Spring Security 认证与授权》)✓
前端守卫"关掉"只影响体验 ——直接 curl 接口照样能调 ✗
这与 《Filter、Listener 与会话管理》 的"前端守卫只是体验层"一致 ✓
export function logout() {
tokens.access = null; // 清内存 ✓
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); // ★ 服务端吊销 refresh
// Cookie 由后端 Set-Cookie 过期指令清除 ✓
}★ 关键:"退出"必须同时做两件事:前端清凭证 + 后端吊销 ✓
只清前端 → token 如果被偷了仍在有效期内可用 ✗
这与 《Filter、Listener 与会话管理》 的"Session invalidate"是同一件事的两端 ✓
localStorage.setItem('token', 'xxx'); // 简单但 XSS 可偷 ⚠️
fetch('/api/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } });见 5.3 的闭环 ✓
GET /api/orders?token=eyJhbGciOi...问题:
| 问题 | 说明 |
|---|---|
| URL 会进日志/历史/Referer ✗✗ | token 泄漏面极大 ✗ |
| 无法设置 HttpOnly ✗ | — |
修:放 Authorization 头 ✓(《Filter、Listener 与会话管理》)✓
| # | 错误 | 说明 |
|---|---|---|
| 1 | refresh token 放 localStorage ✗✗ | 长命凭证被 XSS 偷走 = 长期沦陷 ✗ |
| 2 | access token 永不过期 ✗✗ | 无法止损(《Spring Security 认证与授权》)✗ |
| 3 | 401 时无限 refresh 重试 ✗ | refresh 也失效时要停 ✗(死循环) |
| 4 | 登出只清前端 | 后端 refresh 还有效 ✗(5.5) |
| 5 | 把用户角色存进 JWT 且信任 ⚠️ | JWT 过期前无法收回角色变更 ——敏感操作要再查一次 ✓ |
auth.js(配合 《Spring Security 认证与授权》 的登录接口)✓;document.cookie 读取 refresh token——读不到 ✓;扩展任务:实现"记住我":勾选时 refresh token 有效期 7 天,不勾选时关闭浏览器即失效——说明你把它实现在"前端存储"还是"后端签发",为什么 ✓
localStorage 是最安全的方案。(对 / 错)credentials: 'include' 的作用是什么?后端需要哪两个响应头配合?Allow-Credentials: true 且 Allow-Origin 不能是 * ✓(《Filter、Listener 与会话管理》)★query: { redirect: to.fullPath };登录成功后 router.push(route.query.redirect ?? '/') ✓前端五节(《HTML 与 CSS 基础》 / 《JavaScript 基础》 / 《Ajax 与前后端交互》 / 《Vue 基础与组件开发》 / 《登录状态与 Token 机制》)到此完成。接下来:
暂无