CSRF Token防御机制在前后端分离项目中的实战应用与踩坑总结

欧阳润兴 安全 阅读 1,733
赞 26 收藏
二维码
手机扫码查看
反馈

安全防护还是麻烦制造

上个月接手了一个老项目的安全性重构,其中最重要的就是CSRF防护。其实这块之前也接触过,但真正深入去做才发现,看似简单的CSRF Token在实际项目中还挺复杂,特别是要考虑用户体验和性能的问题。

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防护看起来简单,实际做起来确实有不少需要注意的地方,特别是用户体验和安全性的平衡。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论