如何使用 Cookie 实现"记住我"功能?需要注意哪些安全问题?
使用 Cookie 实现"记住我"功能需要考虑安全性、用户体验和持久化存储等多个方面。"记住我"功能原理在用户登录成功后,生成一个长期有效的认证令牌将令牌存储在持久 Cookie 中用户下次访问时,自动使用 Cookie 中的令牌完成登录实现方案方案 1:持久 Session Cookie// 服务器端设置(Node.js Express)function setRememberMeCookie(res, token, rememberMe) { const options = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/' }; if (rememberMe) { // 长期 Cookie:30天 options.maxAge = 30 * 24 * 60 * 60; } else { // 会话 Cookie:浏览器关闭时删除 options.maxAge = null; } res.cookie('authToken', token, options);}方案 2:双令牌机制// 生成访问令牌和刷新令牌function generateTokens(userId) { const accessToken = jwt.sign( { userId }, process.env.JWT_SECRET, { expiresIn: '15m' } // 短期有效 ); const refreshToken = crypto.randomBytes(32).toString('hex'); // 存储刷新令牌到数据库 db.saveRefreshToken(userId, refreshToken, { expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }); return { accessToken, refreshToken };}// 设置 Cookiefunction setAuthCookies(res, tokens, rememberMe) { // 访问令牌:短期,HttpOnly res.cookie('accessToken', tokens.accessToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 15 * 60 // 15分钟 }); // 刷新令牌:长期,HttpOnly if (rememberMe) { res.cookie('refreshToken', tokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 30 * 24 * 60 * 60 // 30天 }); }}安全最佳实践令牌生成// 使用加密安全的随机数生成器const crypto = require('crypto');function generateSecureToken() { return crypto.randomBytes(32).toString('hex');}令牌存储// 数据库存储方案const refreshTokenSchema = new Schema({ userId: { type: ObjectId, required: true }, token: { type: String, required: true, unique: true }, createdAt: { type: Date, default: Date.now }, expiresAt: { type: Date, required: true }, lastUsedAt: { type: Date, default: Date.now }, userAgent: String, ipAddress: String});令牌验证async function verifyRefreshToken(token, req) { const record = await db.findRefreshToken(token); if (!record) { throw new Error('Invalid token'); } if (record.expiresAt < new Date()) { await db.deleteRefreshToken(token); throw new Error('Token expired'); } // 可选:验证 User-Agent 和 IP if (record.userAgent !== req.headers['user-agent']) { await db.deleteRefreshToken(token); throw new Error('Token compromised'); } // 更新最后使用时间 await db.updateRefreshToken(token, { lastUsedAt: new Date() }); return record.userId;}用户体验优化登录表单<form id="loginForm"> <input type="text" name="username" placeholder="用户名" required> <input type="password" name="password" placeholder="密码" required> <label> <input type="checkbox" name="rememberMe"> 记住我(30天内自动登录) </label> <button type="submit">登录</button></form>自动登录流程// 页面加载时检查 Cookieasync function checkAutoLogin() { const refreshToken = getCookie('refreshToken'); if (refreshToken) { try { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }); if (response.ok) { const { accessToken } = await response.json(); localStorage.setItem('accessToken', accessToken); // 跳转到首页 window.location.href = '/dashboard'; } } catch (error) { console.error('Auto login failed:', error); } }}安全增强措施令牌轮换// 每次使用刷新令牌时生成新的刷新令牌async function rotateRefreshToken(oldToken) { const userId = await verifyRefreshToken(oldToken, req); // 删除旧令牌 await db.deleteRefreshToken(oldToken); // 生成新令牌 const newToken = generateSecureToken(); await db.saveRefreshToken(userId, newToken, { expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }); return newToken;}撤销机制// 用户登出时撤销所有令牌async function logoutAllDevices(userId) { await db.deleteAllRefreshTokens(userId); res.clearCookie('accessToken'); res.clearCookie('refreshToken');}设备管理// 显示已登录设备列表async function getActiveDevices(userId) { const tokens = await db.findRefreshTokensByUser(userId); return tokens.map(token => ({ device: parseUserAgent(token.userAgent), lastUsed: token.lastUsedAt, current: token.userAgent === req.headers['user-agent'] }));}