如何使用 Cookie 实现"记住我"功能?需要注意哪些安全问题?
核心答案
Cookie 实现"记住我"的核心思路是:登录成功后生成一个加密的长期 Token,存入设置了 HttpOnly + Secure + SameSite 的持久化 Cookie,服务端同时将 Token 哈希存入数据库。下次访问时浏览器自动携带 Cookie,服务端校验 Token 哈希完成自动登录,无需用户再次输入密码。
关键安全原则有三条:
- 永远不要在 Cookie 中存储明文密码或密码哈希,只用随机生成的不可预测 Token
- 每次使用后轮换 Token,旧的立即失效,防止重放攻击
- Cookie 必须设置 HttpOnly + Secure + SameSite=Strict,堵住 XSS 窃取、中间人截获、CSRF 伪造三条攻击路径
实现方案对比
方案一:持久 Session Cookie
最简单的方式——延长 Session Cookie 的过期时间:
javascript// 服务端设置(Node.js Express) function setRememberMeCookie(res, token, rememberMe) { const options = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/' }; if (rememberMe) { // 勾选"记住我":30天有效 options.maxAge = 30 * 24 * 60 * 60 * 1000; } else { // 未勾选:会话 Cookie,浏览器关闭即失效 delete options.maxAge; } res.cookie('authToken', token, options); }
优点:实现简单,适用于小型应用。 缺点:Token 不轮换,一旦泄露可被长期使用;单 Token 承载所有功能,撤销困难。
方案二:双令牌机制(推荐)
将短期的访问令牌和长期的刷新令牌分离:
javascriptconst crypto = require('crypto'); const jwt = require('jsonwebtoken'); function generateTokens(userId) { // 访问令牌:短期,用于接口鉴权 const accessToken = jwt.sign( { userId }, process.env.JWT_SECRET, { expiresIn: '15m' } ); // 刷新令牌:长期,仅用于换取新的访问令牌 const refreshToken = crypto.randomBytes(32).toString('hex'); // 服务端存储刷新令牌的哈希值(不是明文) const tokenHash = crypto .createHash('sha256') .update(refreshToken) .digest('hex'); await db.saveRefreshToken({ userId, tokenHash, // 只存哈希,数据库泄露也无法还原 Token expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), userAgent: req.headers['user-agent'], ipAddress: req.ip }); return { accessToken, refreshToken }; } // 设置 Cookie function setAuthCookies(res, tokens, rememberMe) { res.cookie('accessToken', tokens.accessToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 15 * 60 * 1000 // 15分钟 }); if (rememberMe) { res.cookie('refreshToken', tokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 30 * 24 * 60 * 60 * 1000 // 30天 }); } }
优点:访问令牌短命即使泄露影响有限,刷新令牌可单独撤销,支持多设备管理。 缺点:实现复杂度更高,需要额外的刷新接口和存储。
安全防护要点
Token 生成:必须用加密安全随机数
javascript// 正确:crypto.randomBytes const token = crypto.randomBytes(32).toString('hex'); // 错误:Math.random 或时间戳——可预测,可被暴力破解 const badToken = Date.now().toString(36) + Math.random().toString(36);
Math.random() 是伪随机数,攻击者可以通过观察输出模式预测后续值。crypto.randomBytes() 使用操作系统提供的真随机源,不可预测。
Token 存储:数据库只存哈希
数据库中存储 Token 的 SHA-256 哈希,而非明文。这样即使数据库被拖库,攻击者也无法用哈希反推原始 Token 伪造 Cookie。
javascript// 存储 const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); await db.save({ tokenHash, userId, expiresAt }); // 验证 const inputHash = crypto.createHash('sha256').update(cookieToken).digest('hex'); const record = await db.findOne({ tokenHash: inputHash });
Token 轮换:用一次换一个
每次用刷新令牌换新的访问令牌时,同时生成新的刷新令牌,旧的立即删除:
javascriptasync function rotateRefreshToken(oldToken, req) { const inputHash = crypto.createHash('sha256').update(oldToken).digest('hex'); const record = await db.findOne({ tokenHash: inputHash }); if (!record || record.expiresAt < new Date()) { throw new Error('Invalid or expired token'); } // 异地登录检测:User-Agent 或 IP 变化时告警 if (record.userAgent !== req.headers['user-agent']) { // 可选:通知用户,或要求重新验证 await notifyUser(record.userId, '检测到新设备登录'); } // 删除旧令牌 await db.deleteOne({ tokenHash: inputHash }); // 生成新令牌 const newToken = crypto.randomBytes(32).toString('hex'); const newHash = crypto.createHash('sha256').update(newToken).digest('hex'); await db.save({ userId: record.userId, tokenHash: newHash, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), userAgent: req.headers['user-agent'], ipAddress: req.ip }); return newToken; }
不轮换的后果:攻击者偷走 Token 后可以无限期使用,用户改密码也不会失效。
Cookie 属性:三件套缺一不可
| 属性 | 作用 | 不设置的后果 |
|---|---|---|
| HttpOnly | 禁止 JS 读取 Cookie | XSS 攻击可通过 document.cookie 窃取令牌 |
| Secure | 仅 HTTPS 传输 | HTTP 明文传输,中间人可直接截获 |
| SameSite=Strict | 跨站请求不携带 Cookie | CSRF 攻击可伪造用户操作 |
撤销与清理
用户主动登出或修改密码时,必须清除所有刷新令牌:
javascriptasync function revokeAllTokens(userId) { await db.deleteMany({ userId }); // 清除客户端 Cookie res.clearCookie('accessToken'); res.clearCookie('refreshToken'); } // 修改密码后强制所有设备重新登录 async function changePassword(userId, newPassword) { await updateUserPassword(userId, newPassword); await revokeAllTokens(userId); }
客户端自动登录流程
javascript// 页面加载时尝试自动登录 async function checkAutoLogin() { // 浏览器自动携带 HttpOnly Cookie,无需手动读取 const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' // 确保携带 Cookie }); if (response.ok) { const { accessToken } = await response.json(); // 短期访问令牌可存内存(或 sessionStorage),不放 localStorage return accessToken; } // 刷新失败,跳转登录页 window.location.href = '/login'; return null; }
注意:访问令牌不要存 localStorage,因为 XSS 可以直接读取。存在内存变量或 sessionStorage 中更安全。
面试追问
Q1:Cookie 的 SameSite 设为 Strict 会不会影响从外部链接跳转过来的自动登录?
会。SameSite=Strict 意味着任何跨站请求都不带 Cookie,包括从搜索引擎、邮件链接点进来。如果需要兼顾体验,可以用 SameSite=Lax(GET 请求仍携带 Cookie),再配合 CSRF Token 做双重保护。
Q2:刷新令牌被偷了怎么办?
轮换机制本身就在降低风险——旧令牌用一次就作废。更完善的方案是记录每个令牌的 IP 和 User-Agent,发现异常变化时:通知用户、要求二次验证、或直接撤销该用户所有令牌。
Q3:为什么不用 JWT 直接做"记住我"?
JWT 一旦签发就无法撤销(除非引入黑名单,但那就失去了无状态的优势)。长期有效的 JWT 泄露后攻击者可以一直使用到过期。用不透明 Token + 服务端存储 + 轮换机制,撤销只需要删一条数据库记录。
Q4:多设备同时登录怎么管理?
每台设备生成独立的刷新令牌,数据库记录每条令牌的设备信息(User-Agent、IP、最后使用时间)。用户可以在"已登录设备"页面查看并逐个撤销。