1. 从一个真实场景说起:为什么你的请求总是被拒
用 Python 的 requests 库去请求一个 Django 搭建的网站,代码写得好好的,URL 没错、参数也对,可返回的状态码偏偏是 403。打开返回内容一看,提示 CSRF verification failed。这个场景我见过太多次了,不管是做接口自动化测试、写爬虫采集公开数据,还是对接内部系统的 API,只要目标站点用了 Django 默认的 CSRF 防护机制,裸奔的 POST 请求基本都会被拦下来。
这个问题的核心不在于 requests 不好用,而在于 Django 的 CSRF 防护是一套完整的“令牌校验”机制,它要求客户端在提交表单时,必须携带一个由服务端签发、且与当前会话绑定的令牌。requests 默认不会自动处理这套机制,所以你得手动把整个流程串起来。
这篇文章要解决的就是这个问题:如何用 requests 正确地请求带有 CSRF token 校验的 Django 站点。我会从 CSRF 的防护原理讲起,然后一步步拆解完整的请求流程,包括怎么拿 token、怎么带 token、怎么维持会话、怎么处理 token 过期,最后把我踩过的坑和排查技巧都整理出来。适合有一定 Python 基础、正在做接口测试或数据采集的开发者参考,新手也能跟着一步步操作。
2. 先搞懂 Django 的 CSRF 防护到底在防什么
2.1 CSRF 攻击的基本原理
CSRF 全称是 Cross-Site Request Forgery,跨站请求伪造。攻击的逻辑是这样的:你登录了某个网站 A,浏览器里存着 A 的会话 Cookie。这时候你手贱点开了另一个恶意网站 B,B 的页面里藏了一个自动提交的表单,目标地址指向 A 的某个敏感操作接口,比如转账、改密码。因为你浏览器里带着 A 的 Cookie,这个请求发出去之后,A 的服务端会认为是你本人操作的,于是攻击就成功了。
这个攻击的关键在于:浏览器会自动携带目标站点的 Cookie,而服务端光凭 Cookie 无法区分这个请求是你主动发的还是被恶意页面诱导发的。
2.2 Django 的令牌校验机制
Django 的应对方案是引入一个额外的令牌。服务端在渲染表单页面时,会生成一个随机的 CSRF token,同时把它写到页面的隐藏字段里,另外还会在 Cookie 里种一个名为 csrftoken 的值。当你提交表单时,Django 会同时检查两样东西:请求体里的 token 和 Cookie 里的 token,两者必须匹配,而且还要通过一套密钥验证。
这就意味着,攻击者的恶意页面虽然能让浏览器自动带上 Cookie,但它拿不到页面里的那个隐藏 token 值,所以请求就会被拒绝。这套机制的精妙之处在于,token 是嵌入在页面内容里的,而恶意站点受同源策略限制,读不到目标站点的页面内容。
2.3 为什么 requests 默认过不了这一关
requests 是一个通用的 HTTP 客户端,它不会去解析 HTML 页面、提取隐藏字段、维护 Cookie 和 token 的对应关系。你发一个 POST 请求,它就老老实实把你能给的数据发出去,没有 token 就是没有 token,Django 自然不认。
所以我们要做的事情,本质上就是模拟浏览器的行为:先 GET 一次页面拿到 token,把 Cookie 存下来,然后在 POST 的时候把 token 带上,并且保证 Cookie 和 token 是配套的。
3. 完整请求流程的设计思路
3.1 核心思路:先取令牌,再带令牌提交
整个流程可以拆成三步。第一步,用 GET 请求访问包含表单的页面,从响应中提取 CSRF token,同时让 requests 自动保存服务端下发的 Cookie。第二步,从 Cookie 中读取 csrftoken 的值,或者直接复用页面里提取的 token。第三步,构造 POST 请求,在请求头或表单数据中带上 token,同时携带之前保存的 Cookie。
这里有个关键点:token 和 Cookie 必须是同一次会话产生的。如果你用 A 会话拿的 token 配 B 会话的 Cookie,校验照样失败。所以维持会话的一致性非常重要。
3.2 为什么推荐用 Session 对象而不是裸 requests
很多人习惯直接用 requests.get() 和 requests.post(),这样每次请求都是独立的,Cookie 不会自动传递。正确的做法是创建一个 requests.Session() 对象,用它来发所有请求。Session 会自动管理 Cookie,第一次 GET 拿到的 csrftoken 会被存下来,后续 POST 时自动带上,省去了手动处理 Cookie 的麻烦。
我实测下来,用 Session 能让代码简洁一大半,而且不容易出错。裸 requests 需要你手动从 response.cookies 里取值再塞到下次请求的 cookies 参数里,稍不注意就漏了。
3.3 token 放在哪里:请求头还是表单数据
Django 对 token 的接收位置是有讲究的。传统的表单提交,token 放在 POST 数据的 csrfmiddlewaretoken 字段里。而 AJAX 请求,Django 默认期望 token 放在请求头 X-CSRFToken 里。这两种方式 Django 都支持,具体用哪种取决于目标接口的设计。
我的经验是:如果目标是一个传统的表单页面,用表单字段的方式更稳妥;如果是前后端分离的接口,优先用请求头。拿不准的时候,两种都带上也不会有问题,Django 会按优先级去取。
4. 手把手实现:从取 token 到成功提交
4.1 第一步:创建 Session 并获取初始页面
先看代码,这是整个流程的起点:
import requests from bs4 import BeautifulSoup session = requests.Session() # 设置一个常见的浏览器 UA,避免被简单的 UA 过滤拦截 headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" } session.headers.update(headers) # 访问包含表单的页面 login_url = "https://example-site.com/login/" response = session.get(login_url) print(response.status_code)这一步做完之后,session 对象里已经存了服务端下发的 csrftoken Cookie。你可以打印 session.cookies 看一下,通常能看到一个名为 csrftoken 的条目。
注意:有些站点会把 token 藏在 JavaScript 变量里而不是隐藏字段里,这种情况用 BeautifulSoup 解析 HTML 是拿不到的,需要额外用正则去匹配 script 标签里的内容。
4.2 第二步:从页面中提取 CSRF token
拿到页面 HTML 之后,用 BeautifulSoup 定位隐藏字段:
soup = BeautifulSoup(response.text, "html.parser") # Django 表单里的隐藏字段名固定是 csrfmiddlewaretoken token_input = soup.find("input", {"name": "csrfmiddlewaretoken"}) if token_input: csrf_token = token_input.get("value") print("提取到的 token:", csrf_token) else: print("页面里没有找到 csrfmiddlewaretoken 字段")如果页面里确实没有这个字段,别急着放弃。先去 session.cookies 里看看有没有 csrftoken,有的话可以直接用那个值。Django 的校验逻辑是:请求体里的 token 和 Cookie 里的 token 经过同样的密钥处理后应该一致,而页面里的 csrfmiddlewaretoken 本身就是从 Cookie 的 csrftoken 派生出来的,所以直接用 Cookie 里的值往往也能通过。
4.3 第三步:构造带 token 的 POST 请求
现在把 token 带上,提交表单:
post_url = "https://example-site.com/login/" # 方式一:token 放在表单数据里 form_data = { "username": "your_username", "password": "your_password", "csrfmiddlewaretoken": csrf_token, } # 方式二:token 放在请求头里(AJAX 风格) post_headers = { "X-CSRFToken": csrf_token, "Referer": login_url, } response = session.post(post_url, data=form_data, headers=post_headers) print(response.status_code) print(response.text[:500])这里我同时用了两种方式,实际项目中选一种即可。另外注意 Referer 头,Django 在 HTTPS 环境下会校验 Referer 是否同源,如果缺失或者不对,也会触发 403。这个坑我踩过,当时排查了半天才发现是 Referer 的问题。
4.4 完整代码整合
把上面的步骤串起来,形成一个可以直接运行的脚本:
import requests from bs4 import BeautifulSoup def login_with_csrf(login_url, post_url, username, password): session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" }) # 第一步:GET 页面,拿 Cookie 和 token resp = session.get(login_url) soup = BeautifulSoup(resp.text, "html.parser") token_input = soup.find("input", {"name": "csrfmiddlewaretoken"}) if token_input: csrf_token = token_input.get("value") else: csrf_token = session.cookies.get("csrftoken") if not csrf_token: raise RuntimeError("未能获取到 CSRF token,检查页面结构或 Cookie") # 第二步:POST 提交 form_data = { "username": username, "password": password, "csrfmiddlewaretoken": csrf_token, } headers = { "X-CSRFToken": csrf_token, "Referer": login_url, } resp = session.post(post_url, data=form_data, headers=headers) return resp if __name__ == "__main__": result = login_with_csrf( "https://example-site.com/login/", "https://example-site.com/login/", "your_username", "your_password" ) print(result.status_code)这段代码覆盖了大部分 Django 站点的登录场景。你可以根据目标站点的实际字段名做调整。
5. 参数细节与常见坑点解析
5.1 Cookie 与 token 的绑定关系
Django 的 CSRF 校验有一个容易被忽略的细节:它会把 Cookie 里的 csrftoken 和请求里的 token 做一次“加盐”处理,然后比对结果。这个加盐用的密钥是服务端随机生成的,每次请求可能不同。所以你不能自己伪造一个 token,必须用服务端下发的。
另外,如果你在同一个 Session 里多次 GET 页面,csrftoken 可能会被刷新。这时候旧的 token 就失效了,必须用最新的。我遇到过一种情况:先 GET 了页面 A 拿到 token,然后又 GET 了页面 B,结果 B 刷新了 Cookie,导致 A 的 token 作废。解决办法是每次 POST 之前重新 GET 一次目标页面,确保 token 是最新的。
5.2 Referer 校验的触发条件
Django 的 CsrfViewMiddleware 在 HTTPS 请求下会检查 Referer 头。如果请求是 HTTPS 的,而 Referer 缺失或者域名不匹配,就会直接拒绝。这个行为由 CSRF_TRUSTED_ORIGINS 配置控制,但作为客户端我们改不了服务端配置,只能老老实实把 Referer 带上。
实测下来,Referer 的值应该是目标站点的完整 URL,包括协议和域名。有些站点还会校验路径,所以最好用表单页面的 URL 作为 Referer。
5.3 token 过期与刷新策略
CSRF token 是有有效期的,默认和会话绑定。如果会话过期了,token 也就失效了。在长时间运行的脚本里,你需要处理 token 过期的情况。我的做法是封装一个重试逻辑:如果 POST 返回 403,就重新 GET 一次页面刷新 token,然后再试一次。
def post_with_retry(session, url, data, headers, max_retries=2): for attempt in range(max_retries): resp = session.post(url, data=data, headers=headers) if resp.status_code != 403: return resp # 403 了,刷新 token 再试 page = session.get(url) soup = BeautifulSoup(page.text, "html.parser") token_input = soup.find("input", {"name": "csrfmiddlewaretoken"}) if token_input: new_token = token_input.get("value") data["csrfmiddlewaretoken"] = new_token headers["X-CSRFToken"] = new_token return resp这个重试逻辑在实际项目中非常实用,尤其是做批量操作的时候。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 返回 403,提示 CSRF verification failed | 请求里没有 token | 检查是否提取并携带了 token |
| 返回 403,提示 CSRF cookie not set | Cookie 里没有 csrftoken | 确认先 GET 了页面,且 Session 正常保存 Cookie |
| 返回 403,提示 Referer checking failed | Referer 缺失或不对 | 添加正确的 Referer 头 |
| token 提取为空 | 页面结构变了或 token 在 JS 里 | 检查页面源码,改用正则匹配 |
| 第一次成功,第二次失败 | token 被刷新了 | 每次请求前重新获取 token |
| 本地测试通过,线上失败 | 环境差异导致 Cookie 策略不同 | 检查域名、协议、端口是否一致 |
6. 进阶场景:接口测试与批量请求中的实践
6.1 在接口自动化测试中复用 Session
做接口测试的时候,通常需要先登录拿到会话,然后连续调用多个接口。这时候 Session 对象的复用就很重要。我的做法是把登录逻辑封装成一个 fixture,在测试开始前执行一次,后续所有请求都复用这个 Session。
import pytest @pytest.fixture(scope="session") def logged_in_session(): session = requests.Session() # 执行登录流程 # ... return session def test_get_profile(logged_in_session): resp = logged_in_session.get("https://example-site.com/api/profile/") assert resp.status_code == 200这样写的好处是登录只做一次,测试跑得快,而且不会因为频繁登录触发风控。
6.2 处理前后端分离的接口
现在很多 Django 项目用 DRF 做后端,前端用 Vue 或 React。这种架构下,CSRF token 通常通过 Cookie 下发,前端从 Cookie 里读出来放到请求头里。用 requests 模拟的时候,流程是一样的:先 GET 一个接口让服务端种下 csrftoken,然后从 session.cookies 里读出来,放到 X-CSRFToken 头里。
需要注意的是,有些 DRF 项目会关闭 CSRF 校验(用 @csrf_exempt 装饰器),这时候你反而不用带 token。所以动手之前,先用浏览器开发者工具看一下实际请求带了什么,照着抄就行。
6.3 批量请求时的性能考量
如果需要发起大量请求,每次都 GET 页面拿 token 会很慢。优化思路是:登录之后,token 在会话有效期内是复用的,不需要每次请求都重新获取。只有在收到 403 的时候才去刷新。这样能把请求量降下来。
另外,Session 对象本身是线程不安全的,如果你用多线程并发请求,每个线程应该有自己的 Session。这一点在做压力测试的时候要特别注意。
7. 我踩过的那些坑和排查心得
第一个坑是 UA 的问题。有些站点会对 requests 默认的 User-Agent 做拦截,直接返回 403,但提示信息不是 CSRF 相关的,容易误导排查方向。所以第一步就应该把 UA 设置成常见浏览器。
第二个坑是 Cookie 的作用域。如果你访问的页面和提交的接口不在同一个路径下,Cookie 的 path 属性可能导致 Cookie 不被发送。这种情况需要检查 Set-Cookie 头里的 path 字段,必要时手动调整。
第三个坑是 HTTPS 证书。有些测试环境用的是自签名证书,requests 默认会校验失败。可以用 verify=False 跳过,但生产环境千万别这么干。
排查的时候,我习惯用 curl 或者浏览器开发者工具先手动跑一遍流程,把实际的请求头、Cookie、表单数据都抓下来,然后照着用 requests 复现。这样比盲猜快得多。另外一个技巧是打开 Django 的日志,看看服务端到底是因为哪个环节拒绝的,日志里通常会写明具体原因。
最后分享一个小经验:如果你拿到的 token 里包含换行符或者空格,记得 strip 一下。我有一次就是因为 token 末尾多了个换行,导致校验一直失败,排查了快一个小时才发现。这种细节问题最折磨人,但也最容易避免。