目录
- 引言
- Token存储位置对比
- 常见安全威胁
- [HttpOnly + Secure + SameSite Cookie方案详解](#HttpOnly + Secure + SameSite Cookie方案详解 "#httponly--secure--sameSite-cookie%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
- 双Token认证方案
- 不同场景下的最佳实践
- 代码实现示例
- 总结
引言
用户登录后获取的Token(令牌)是用户身份的临时凭证,正确存储和使用Token对应用安全至关重要。本文将详细讨论Token的存储位置、安全威胁、防御措施以及最佳实践,帮助开发者构建更安全的认证系统。
Token存储位置对比
localStorage/sessionStorage
- 优点 :
- 使用简单,API友好
- 前端可随时读写
- 容量较大(通常5MB)
- sessionStorage在会话结束后自动清除
- 缺点 :
- 易受XSS攻击(JavaScript可直接读取)
- 不适合存储敏感信息
- 无法设置过期时间(需手动管理)
内存(变量/状态管理)
- 优点 :
- 页面刷新后丢失,降低被窃取的时间窗口
- JavaScript不易持久化
- 不受同源策略限制
- 缺点 :
- 页面刷新会丢失,需要刷新机制
- 标签页关闭后无法恢复
- 无法在多标签页间共享
Cookie
- 优点 :
- 可设置HttpOnly防止JavaScript读取
- 自动随请求发送到服务器
- 可设置过期时间和域范围
- 配合SameSite可抵御部分CSRF攻击
- 缺点 :
- 容量小(通常4KB)
- 默认随请求自动发送,需防CSRF
- 跨域复杂,受同源策略限制
- 用户可手动清除或禁用
常见安全威胁
XSS(跨站脚本攻击)
攻击原理:攻击者在网页中注入恶意JavaScript代码,当用户访问该页面时,恶意代码会在用户的浏览器中执行。
生活案例:就像有人在银行大厅安装了隐形摄像头,当你输入密码时,他可以看到你的一举一动。
Token风险:如果Token存储在localStorage或普通Cookie中,恶意JavaScript可以读取并发送到攻击者的服务器。
CSRF(跨站请求伪造)
攻击原理:攻击者诱导已登录用户访问恶意网站,该网站会"代替用户"向目标网站发送请求,利用浏览器会自动携带Cookie的特性。
生活案例:想象你收到一封看似银行的邮件,点击链接后,实际上触发了一个转账请求。因为你已登录银行网站,银行会认为这是你本人操作。
场景演示:
- 用户登录了银行网站A
- 用户访问恶意网站B
- B网站包含一个表单,自动提交到A网站的转账接口
- 浏览器发送请求时会自动携带A网站的Cookie
- A网站验证Cookie有效,执行转账操作
关键点:攻击者不需要读取Cookie内容,只需让浏览器自动携带Cookie发起请求。
中间人攻击
攻击原理:攻击者位于用户与服务器之间,可以拦截和修改通信内容。
生活案例:你以为在和银行柜员对话,实际上中间有人在传话,可能篡改你的指令。
Token风险:如果不使用HTTPS,Token在传输过程中可能被窃取。
HttpOnly + Secure + SameSite Cookie方案详解
HttpOnly
- 作用:禁止JavaScript通过document.cookie访问Cookie
- 防御:有效防止XSS攻击读取Cookie中的Token
- 限制:只防止读取,不防止CSRF(因为浏览器仍会自动发送Cookie)
Secure
- 作用:仅在HTTPS连接中发送Cookie
- 防御:防止明文传输被窃听
- 必要性:现代Web应用必须启用
SameSite
- 作用:控制跨站请求是否携带Cookie
- 选项 :
Lax(默认):顶级导航(如点击链接)会发送Cookie,但大多数跨站子请求(如图片加载)不会Strict:只有同站请求才发送CookieNone:允许跨站请求发送Cookie,但必须同时设置Secure
- 防御效果 :
Lax可防御大部分CSRF攻击,但不完美Strict安全性最高,但用户体验可能受影响None需要额外CSRF防御措施
配置示例
ini
Set-Cookie: token=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
双Token认证方案
概念解释
- Access Token:短期访问令牌,用于API请求认证
- Refresh Token:长期刷新令牌,用于获取新的Access Token
工作流程
- 用户登录成功,服务器返回Access Token和Refresh Token
- Access Token存储在内存中,Refresh Token存储在HttpOnly Cookie中
- 每次API请求使用Access Token认证
- Access Token过期后,使用Refresh Token获取新的Access Token
- 如果Refresh Token也过期,用户需要重新登录
安全增强措施
- Token轮换:每次刷新都生成新的Refresh Token,旧Token立即失效
- 复用检测:如果旧Refresh Token被再次使用,说明可能被盗用,立即撤销所有Token
- 设备绑定:将Token与设备指纹关联,防止跨设备使用
生活案例
就像游乐园的"腕带+身份证"系统:
- 腕带(Access Token):当天有效,用于快速进入各项目,丢失影响小
- 身份证(Refresh Token):长期有效,只在腕带失效时用于换取新腕带,平时妥善保管
不同场景下的最佳实践
Web单页应用(SPA)
- Access Token存内存,通过Authorization头发送
- Refresh Token存HttpOnly + Secure + SameSite Cookie
- 实现CSRF Token机制
- 全站HTTPS
服务端渲染(SSR)
- 优先使用服务器会话
- 通过HttpOnly会话Cookie维持状态
- 前端不直接接触Token
移动应用
- 使用系统安全存储(iOS Keychain/Android Keystore)
- 实现证书固定(Certificate Pinning)
- 考虑生物认证(指纹/面部识别)
跨域应用
- 谨慎设置Cookie Domain
- 必要时使用SameSite=None + Secure
- 强化CSRF防御和CORS配置
代码实现示例
后端实现(Node.js + Express)
javascript
// 登录接口
app.post('/api/login', (req, res) => {
// 验证用户凭据
const { username, password } = req.body;
// 假设验证通过
const accessToken = generateAccessToken(username);
const refreshToken = generateRefreshToken(username);
// 设置Refresh Token为HttpOnly Cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
});
// 返回Access Token
res.json({
accessToken,
expiresIn: 900 // 15分钟
});
});
// 刷新Token接口
app.post('/api/refresh', (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ message: '未授权' });
}
// 验证Refresh Token
try {
const user = verifyRefreshToken(refreshToken);
// 生成新Token
const newAccessToken = generateAccessToken(user.username);
const newRefreshToken = rotateRefreshToken(user.username, refreshToken);
// 设置新的Refresh Token
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({
accessToken: newAccessToken,
expiresIn: 900
});
} catch (err) {
res.clearCookie('refresh_token');
return res.status(401).json({ message: '刷新Token无效' });
}
});
// 登出接口
app.post('/api/logout', (req, res) => {
// 清除Refresh Token Cookie
res.clearCookie('refresh_token');
// 在服务器端将Refresh Token加入黑名单
blacklistRefreshToken(req.cookies.refresh_token);
res.json({ message: '登出成功' });
});
前端实现(React)
javascript
// 认证上下文
const AuthContext = createContext();
function AuthProvider({ children }) {
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
// 初始化检查登录状态
useEffect(() => {
checkAuth();
}, []);
// 登录函数
const login = async (username, password) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include' // 重要:允许发送和接收Cookie
});
if (!response.ok) throw new Error('登录失败');
const data = await response.json();
setAccessToken(data.accessToken);
// 设置自动刷新
setTimeout(refreshToken, (data.expiresIn - 60) * 1000);
return true;
} catch (error) {
console.error('登录错误:', error);
return false;
}
};
// 刷新Token
const refreshToken = async () => {
try {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
setAccessToken(null);
return false;
}
const data = await response.json();
setAccessToken(data.accessToken);
// 设置下次刷新
setTimeout(refreshToken, (data.expiresIn - 60) * 1000);
return true;
} catch (error) {
setAccessToken(null);
return false;
}
};
// 登出
const logout = async () => {
try {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include'
});
} finally {
setAccessToken(null);
}
};
// API请求拦截器
const authFetch = async (url, options = {}) => {
// 添加Authorization头
const authOptions = {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`
}
};
try {
const response = await fetch(url, authOptions);
// 如果返回401,尝试刷新Token
if (response.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
// 使用新Token重试请求
authOptions.headers.Authorization = `Bearer ${accessToken}`;
return fetch(url, authOptions);
} else {
throw new Error('未授权');
}
}
return response;
} catch (error) {
console.error('请求错误:', error);
throw error;
}
};
// 检查是否已登录
const checkAuth = async () => {
setLoading(true);
try {
const refreshed = await refreshToken();
setLoading(false);
return refreshed;
} catch (error) {
setLoading(false);
return false;
}
};
const value = {
accessToken,
isAuthenticated: !!accessToken,
login,
logout,
authFetch,
loading
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 使用Hook
function useAuth() {
return useContext(AuthContext);
}
CSRF防御实现
javascript
// 后端生成CSRF Token
app.get('/api/csrf-token', (req, res) => {
const csrfToken = generateRandomToken();
// 存储在普通Cookie中
res.cookie('csrf_token', csrfToken, {
secure: true,
sameSite: 'lax'
});
res.json({ csrfToken });
});
// CSRF保护中间件
function csrfProtection(req, res, next) {
// 跳过GET请求
if (req.method === 'GET') return next();
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({ message: 'CSRF验证失败' });
}
next();
}
// 应用到需要保护的路由
app.post('/api/sensitive-action', csrfProtection, (req, res) => {
// 处理敏感操作
});
// 前端实现
async function performSensitiveAction() {
// 从Cookie中读取CSRF Token
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
// 发送请求时在头部包含CSRF Token
const response = await fetch('/api/sensitive-action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'include',
body: JSON.stringify({ /* 数据 */ })
});
return response.json();
}
总结
最佳实践清单
-
Web应用:
- Access Token存内存,通过Authorization头发送
- Refresh Token存HttpOnly + Secure + SameSite Cookie
- 实现CSRF Token机制
- 全站HTTPS
- 敏感操作不使用GET方法
-
Token安全:
- Access Token短期有效(5-15分钟)
- Refresh Token适中有效期(7-30天)
- 实现Token轮换与复用检测
- 使用JTI(JWT ID)管理Token撤销
-
防御措施:
- XSS防御:CSP、输入验证、输出编码
- CSRF防御:SameSite Cookie + CSRF Token
- 中间人防御:HTTPS + 证书固定
安全与用户体验平衡
安全性和用户体验往往需要权衡。最佳方案应根据应用场景、用户群体和安全需求来确定。双Token方案在大多数情况下能提供良好的安全性和用户体验平衡。
最终建议
无论选择哪种方案,都应定期审计安全措施,关注新的安全威胁,并及时更新防御策略。安全是一个持续过程,而非一次性工作。