CSRF Token防御机制在前后端分离项目中的实战应用与踩坑总结
安全防护还是麻烦制造
上个月接手了一个老项目的安全性重构,其中最重要的就是CSRF防护。其实这块之前也接触过,但真正深入去做才发现,看似简单的CSRF Token在实际项目中还挺复杂,特别是要考虑用户体验和性能的问题。
项目本身是个中型的管理后台系统,PHP + Vue.js架构,用户量不大不小,大概几千人在线。老板的意思很明确,安全不能出问题,但也不能严重影响现有功能。刚开始觉得不就是加个Token嘛,结果后来踩了不少坑。
前端怎么搞Token获取
最开始的想法很简单,页面加载时获取Token,然后每次请求都带上。但实际操作起来发现几个问题:
第一个问题是Token的有效期管理。我们采用了服务端生成Token,前端存储的方式:
// PHP后端生成Token
function generateCSRFToken() {
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
return $token;
}
// API接口返回Token
if ($_GET['action'] === 'get_csrf_token') {
header('Content-Type: application/json');
echo json_encode(['token' => generateCSRFToken()]);
}
// 前端获取和管理Token
class CSRFManager {
constructor() {
this.token = null;
this.expiresAt = null;
this.init();
}
async init() {
await this.fetchToken();
// 每15分钟刷新一次Token
setInterval(() => this.refreshToken(), 15 * 60 * 1000);
}
async fetchToken() {
try {
const response = await fetch('/api/csrf-token', {
method: 'GET',
credentials: 'include'
});
const data = await response.json();
this.token = data.token;
this.expiresAt = Date.now() + 30 * 60 * 1000; // 30分钟有效期
} catch (error) {
console.error('获取CSRF Token失败:', error);
}
}
async refreshToken() {
if (!this.token || Date.now() > this.expiresAt - 5 * 60 * 1000) {
await this.fetchToken();
}
}
getValidToken() {
if (!this.token || Date.now() > this.expiresAt) {
throw new Error('CSRF Token已过期');
}
return this.token;
}
}
const csrfManager = new CSRFManager();
这里有个坑就是并发请求的问题。如果多个请求同时需要新的Token,就会造成重复请求。后来加了个锁来解决:
class CSRFManager {
constructor() {
this.token = null;
this.expiresAt = null;
this.isRefreshing = false;
this.pendingRequests = [];
}
async fetchToken() {
if (this.isRefreshing) {
// 如果正在刷新,等待刷新完成
return new Promise((resolve) => {
this.pendingRequests.push(resolve);
});
}
this.isRefreshing = true;
try {
const response = await fetch('/api/csrf-token', {
method: 'GET',
credentials: 'include'
});
const data = await response.json();
this.token = data.token;
this.expiresAt = Date.now() + 30 * 60 * 1000;
// 通知等待的请求
this.pendingRequests.forEach(resolve => resolve(this.token));
this.pendingRequests = [];
} catch (error) {
console.error('获取CSRF Token失败:', error);
this.pendingRequests.forEach(resolve => resolve(null));
this.pendingRequests = [];
} finally {
this.isRefreshing = false;
}
}
}
服务端验证的那些事儿
服务端这块其实更复杂。我们用了Laravel的CSRF中间件作为参考,自己实现了一套:
class CSRFMiddleware {
public static function verify($request) {
$expectedToken = $_SESSION['csrf_token'] ?? '';
$actualToken = $request->post('_token') ?:
$request->header('X-CSRF-TOKEN') ?:
$request->header('X-XSRF-TOKEN');
if (empty($actualToken) || $actualToken !== $expectedToken) {
throw new Exception('CSRF token mismatch', 419);
}
// 验证成功后生成新Token(防止重放攻击)
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
这里的重放攻击防护是个重点。验证成功后立即更新Token,这样即使Token被截获,也无法再次使用。但这也带来了一个问题:如果前端的Token没有及时更新,下次请求就会失败。
所以我们在响应中也要返回新的Token:
class CSRFController {
public function handleRequest($request) {
try {
CSRFMiddleware::verify($request);
// 执行业务逻辑
$result = $this->processBusinessLogic($request);
// 返回结果的同时返回新的Token
return [
'data' => $result,
'csrf_token' => $_SESSION['csrf_token'] // 新的Token
];
} catch (Exception $e) {
http_response_code($e->getCode());
return ['error' => $e->getMessage()];
}
}
}
Axios拦截器的完美配合
前端这边主要通过Axios拦截器来自动处理Token的添加和更新:
import axios from 'axios';
// 请求拦截器:添加CSRF Token
axios.interceptors.request.use(
async (config) => {
await csrfManager.refreshToken();
const token = csrfManager.getValidToken();
if (config.method.toLowerCase() !== 'get') {
config.headers['X-CSRF-TOKEN'] = token;
// 对于表单提交,也可能需要在body中添加
if (config.data && typeof config.data === 'object') {
config.data._token = token;
}
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:更新Token
axios.interceptors.response.use(
(response) => {
// 检查响应中是否有新的Token
if (response.data && response.data.csrf_token) {
csrfManager.token = response.data.csrf_token;
}
return response;
},
(error) => {
// Token过期错误的特殊处理
if (error.response && error.response.status === 419) {
// 尝试重新获取Token并重试请求
return csrfManager.fetchToken().then(() => {
// 重新发送原请求
return axios.request(error.config);
}).catch(() => {
// 如果还是失败,就让用户重新登录
window.location.href = '/login';
});
}
return Promise.reject(error);
}
);
这里最大的挑战是如何优雅地处理Token过期的情况。我们采用的是先尝试刷新Token,然后重试原请求的策略。这样做用户体验比较好,不会因为Token过期就强制跳转登录页。
性能考虑和优化
项目上线后发现了一些性能问题。主要是Token的频繁获取会影响页面加载速度。后来做了几个优化:
- Token缓存到localStorage,减少不必要的网络请求
- 只在需要的时候才刷新Token,而不是定时刷新
- 对于静态资源请求不携带Token头部
最终的效果还可以,基本不影响用户体验。虽然偶尔会有几个Token相关的错误,但大多是浏览器兼容性问题,影响范围很小。
一些遗留问题
目前还存在一个小问题:如果用户在多个标签页同时操作,可能会出现Token冲突。这个问题比较难解决,因为我们不想引入太复杂的同步机制。暂时的方案是在错误率可控范围内,让用户重新操作即可。
总的来说,这套CSRF防护体系还是起到了应有的作用,安全测试也没有发现问题。虽然还有些小瑕疵,但已经能满足项目需求了。
以上是我踩坑后的总结,希望对你有帮助。CSRF防护看起来简单,实际做起来确实有不少需要注意的地方,特别是用户体验和安全性的平衡。有更优的实现方式欢迎评论区交流。

暂无评论