1. 项目背景
业务场景
某安全团队的渗透测试发现一个严重漏洞:部分微服务在调用第三方 API 时,即使 HTTP 状态码是 401(认证失败),代码仍然继续处理响应体——因为开发者用resp.status_code == 200做判断,而 requests 在收到 401 后不会自动抛异常。这个漏洞的修复需要理解 requests 的认证流程:auth 是如何在 Session.send() 中被应用的?为什么raise_for_status()不会自动被调用?
另一个场景更隐蔽:他们在实现一个 OAuth2 客户端,遇到了 Token 刷新时"多线程并发刷新"的问题。通过阅读auth.py中HTTPDigestAuth的 401 处理流程,他们理解了 auth 重试机制的单次性——requests 只重试一次,不会无限循环。
痛点
问题一:AuthBase 协议的本质是 PreparedRequest 的修改器。auth.py的核心抽象极简——一个__call__(self, r: PreparedRequest) -> PreparedRequest方法。但很少有开发者理解:auth 在什么时机被调用?如何与 Session 的其他设置交互?如果 auth 返回了一个全新的 PreparedRequest 而不是修改原对象,会发生什么?
问题二:HTTPDigestAuth 的完整实现是 auth.py 中最复杂的代码。它涉及 MD5 哈希计算、nonce 管理、qop(quality of protection)协商、401 响应解析——约 150 行代码。理解这段代码不仅帮你理解 Digest Auth,更是学习"如何在 AuthBase 中实现有状态的认证协议"的最佳案例。
问题三:认证与重定向的交互。当发生 3xx 重定向时,auth 信息是否会被带过去?默认行为是:对跨域重定向,requests 会剥离 Authorization 头(防止凭据泄露到不信任的域)。这个行为在rebuild_auth()函数中实现。
auth.py 核心类关系图:
AuthBase (抽象基类) ├── __call__(self, r: PreparedRequest) -> PreparedRequest │ ├── HTTPBasicAuth │ └── __call__: r.headers["Authorization"] = "Basic " + b64(user:pass) │ ├── HTTPDigestAuth │ ├── __call__: 处理 401 响应, 计算 Digest response │ ├── build_digest_header(): 构造 WWW-Authenticate 响应 │ ├── handle_401(): 解析 nonce/qop/algorithm/opaque │ └── HA1/HA2/response 的 MD5 计算链 │ └── 自定义 AuthBase (用户实现) └── __call__: 任意逻辑修改 PreparedRequest 工具函数: extract_cookies_to_jar(): 从响应中提取 Cookie 到 CookieJar _basic_auth_str(): 构造 Basic Auth 的 Base64 字符串2. 项目设计
小胖(对着日志输出迷惑):
“大师!我用 Session 设置了auth=HTTPBasicAuth("user","pass"),然后请求一个不需要认证的接口——为什么日志里同一个 URL 出现了两次请求?一次 401,一次 200?”
大师(微笑着打开 auth.py 源码):
“这就是 requests 的 auth 重试机制。Session.send()在发送请求时,不会主动带上 Authorization 头(避免凭据泄露给不信任的服务器)。它先发一个不带凭据的请求——如果收到 401,并且响应头中有WWW-Authenticate,就再用 auth 处理器重试一次。所以你看到的两次请求是正常的——第一次试探,第二次认证。”
小白(推了推眼镜):
“那为什么HTTPDigestAuth比HTTPBasicAuth复杂那么多?Basic Auth 就一行 Base64,Digest Auth 好像有上百行代码。”
大师:
“因为 Digest Auth 需要解析服务器返回的 401 响应——提取 nonce、realm、qop、algorithm、opaque 等参数,用这些参数加上用户名密码计算出 response 值。而且它需要在第一次请求失败后(收到 401),解析 WWW-Authenticate 头,重新计算,再发第二次请求。整个状态机比 Basic Auth 复杂得多。”
# auth.py 中 HTTPDigestAuth.handle_401() 的核心逻辑 (简化)defhandle_401(self,r,**kwargs):# 1. 解析 WWW-Authenticate: Digest realm="...", nonce="..."www_auth=r.headers.get("www-authenticate","")# 2. 提取参数: realm, nonce, qop, algorithm, opaque# 3. 计算 HA1 = MD5(user:realm:pass)# 4. 计算 HA2 = MD5(method:uri)# 5. 计算 response = MD5(HA1:nonce:nc:cnonce:qop:HA2)# 6. 构建 Authorization: Digest ...头# 7. 重新发送带认证头的请求小胖:
“那rebuild_auth()是干什么的?我发现在重定向时 Authorization 头会被丢弃?”
大师:
“对——这是安全设计。rebuild_auth()在重定向时判断:如果新 URL 的域名和原 URL 不同(跨域重定向),就剥离 Authorization 头,防止凭据泄露到不信任的域。如果同域,就保留。”
# Session.send() 中的 redirect 循环 (简化)whileTrue:resp=adapter.send(request)ifresp.status_codein(301,302,303,307,308):request=rebuild_auth(request,resp)# 剥离或保留 authcontinuebreak| 生活比喻 | 技术映射 |
|---|---|
| 先敲门再报身份 | 首次无凭据请求 -> 401 -> 带凭据请求 |
| 银行柜台的挑战-应答 | HTTPDigestAuth 的 nonce 机制 |
| 跨省办事需要重新出示身份证 | 跨域重定向剥离 Authorization |
| 自助制卡机(插卡->认证->出卡) | AuthBase.call修改 PreparedRequest |
| 万能钥匙(一把钥匙开多道门) | Session.auth 全局认证 |
3. 项目实战
环境准备
python-c"import requests.auth; print(requests.auth.__file__)"分步实现
步骤一:HTTPBasicAuth 源码走读
目标:从源码层面理解 Basic Auth 的实现。
importrequestsfromrequests.authimportHTTPBasicAuth,_basic_auth_strimportbase64defdemo_basic_auth_internals():"""演示 HTTPBasicAuth 的源码实现"""# HTTPBasicAuth.__call__ 的核心逻辑 (源码简化版):# def __call__(self, r):# r.headers["Authorization"] = _basic_auth_str(self.username, self.password)# return r# _basic_auth_str 的实现:# def _basic_auth_str(username, password):# if isinstance(username, str):# username = username.encode("latin1")# if isinstance(password, str):# password = password.encode("latin1")# auth = base64.b64encode(b":".join((username, password))).strip()# return "Basic " + auth.decode("ascii")# 关键细节: 用户名和密码用 latin1 编码(非 UTF-8!)# 这意味着中文用户名/密码可能编码失败auth=HTTPBasicAuth("admin","secret123")# 构建 PreparedRequestsession=requests.Session()req=requests.Request("GET","https://httpbin.org/basic-auth/admin/secret123")prepared=session.prepare_request(req)print("发送前 (无auth):")print(f" Authorization:{prepared.headers.get('Authorization','(无)')}")# 应用 authprepared=auth(prepared)print(f"\n应用 auth 后:")print(f" Authorization:{prepared.headers['Authorization']}")# 解码验证encoded=prepared.headers["Authorization"].split(" ",1)[1]decoded=base64.b64decode(encoded).decode("latin1")print(f" 解码后:{decoded}")# 源码中的 latin1 编码限制:# username.encode("latin1") — 非 latin1 字符会报错try:HTTPBasicAuth("中文用户","pass")print("\n 注意: 用户名含中文时 latin1 编码可能失败")exceptUnicodeEncodeError:print("\n ⚠ 中文用户名会导致 latin1 编码错误!")session.close()demo_basic_auth_internals()步骤二:HTTPDigestAuth 的 401 处理流程
目标:理解 Digest Auth 的 challenge-response 机制。
importrequestsfromrequests.authimportHTTPDigestAuthimporthashlibdefdemo_digest_auth_flow():"""演示 Digest Auth 的 challenge-response 流程"""# 简化版计算演示(真实的 HTTPDigestAuth 更复杂)username="user"password="passwd"realm="me@kennethreitz.com"# httpbin 的 realmmethod="GET"uri="/digest-auth/auth/user/passwd"nonce="example-nonce"# 服务器返回的随机数# 1. HA1 = MD5(username:realm:password)ha1=hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()print(f"HA1 = MD5({username}:{realm}:{password})")print(f" ={ha1}")# 2. HA2 = MD5(method:uri)ha2=hashlib.md5(f"{method}:{uri}".encode()).hexdigest()print(f"\nHA2 = MD5({method}:{uri})")print(f" ={ha2}")# 3. response = MD5(HA1:nonce:HA2)response=hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()print(f"\nresponse = MD5({ha1[:8]}...:{nonce}:{ha2[:8]}...)")print(f" ={response}")# 4. 构造 Authorization 头auth_header=(f'Digest username="{username}", 'f'realm="{realm}", 'f'nonce="{nonce}", 'f'uri="{uri}", 'f'response="{response}"')print(f"\nAuthorization:{auth_header[:80]}...")# 实际使用print(f"\n===== 真实 Digest Auth 请求 =====")try:resp=requests.get("https://httpbin.org/digest-auth/auth/user/passwd",auth=HTTPDigestAuth("user","passwd"),)print(f"状态码:{resp.status_code}")print(f"认证:{resp.json().get('authenticated','N/A')}")exceptExceptionase:print(f"注意:{e}")demo_digest_auth_flow()步骤三:从源码角度实现一个自定义 Auth——自动 Token 刷新
目标:基于对 auth 源码的理解,实现一个生产级的 OAuth2 AuthBase。
importrequestsfromrequests.authimportAuthBaseimporttimeimportthreadingclassAutoRefreshOAuth2Auth(AuthBase):"""生产级 OAuth2 自动刷新认证处理器 设计思路 (源自对 auth.py 源码的理解): - AuthBase.__call__ 在 Session.send() 中被调用 - 在每次请求前检查 Token 是否过期 - 过期时刷新 Token(加锁防并发) - 直接修改 PreparedRequest 并返回 """def__init__(self,token_url:str,client_id:str,client_secret:str,scope:str="",refresh_margin:int=60):self.token_url=token_url self.client_id=client_id self.client_secret=client_secret self.scope=scope self.refresh_margin=refresh_margin self._access_token=Noneself._expires_at=0self._lock=threading.Lock()def__call__(self,r):"""核心方法——在每次请求前被 Session.send() 调用"""ifself._should_refresh():self._refresh_token_sync()r.headers["Authorization"]=f"Bearer{self._access_token}"returnrdef_should_refresh(self)->bool:returnself._access_tokenisNoneortime.time()>self._expires_atdef_refresh_token_sync(self):withself._lock:# 双重检查(可能已被其他线程刷新)ifnotself._should_refresh():returnprint("[OAuth2] 正在刷新 Token...")resp=requests.post(self.token_url,data={"grant_type":"client_credentials","client_id":self.client_id,"client_secret":self.client_secret,"scope":self.scope,},timeout=10)resp.raise_for_status()data=resp.json()self._access_token=data["access_token"]expires_in=data.get("expires_in",3600)self._expires_at=time.time()+expires_in-self.refresh_marginprint(f"[OAuth2] Token 刷新成功, 有效期{expires_in}s")@propertydeftoken_info(self)->dict:ifself._access_tokenisNone:return{"status":"no_token"}remaining=self._expires_at-time.time()return{"status":"active"ifremaining>0else"expired","remaining_s":round(remaining,0),}# ===== 使用演示 =====print("===== OAuth2 AutoRefresh 认证器 =====")print("用法: session.auth = AutoRefreshOAuth2Auth(...)")print(" - 首次请求自动获取 Token")print(" - Token 过期前 margin 秒自动刷新")print(" - 并发安全(threading.Lock + 双重检查)")print(" - 基于 AuthBase 协议,零侵入")可能遇到的坑及解决方法
坑1:Basic Auth 的 latin1 编码限制
# HTTPBasicAuth 用 latin1 编码用户名和密码(非 UTF-8)# 中文用户名会抛出 UnicodeEncodeError# 解决: 如果必须用中文,自定义 AuthBase 使用 UTF-8 编码坑2:Digest Auth 的 qop 兼容性
# 不同服务器对 qop (quality of protection) 的支持不同# 有的服务器要求 qop=auth,有的不支持 qop# HTTPDigestAuth 源码中有复杂的 qop 协商逻辑坑3:auth 在 Session.send() 中被调用两次
# 第一次: 无凭据请求 -> 401# 第二次: 带凭据请求 -> 200# 如果你的 AuthBase 有副作用(如计数、日志),会被调用两次测试验证
importpytestimportrequestsfromrequests.authimportHTTPBasicAuth,AuthBaseclassTestAuthInternals:"""验证 auth 内部机制"""deftest_basic_auth_encodes_correctly(self):fromrequests.authimport_basic_auth_str auth_str=_basic_auth_str("user","pass")assertauth_str.startswith("Basic ")importbase64 decoded=base64.b64decode(auth_str.split()[1]).decode("latin1")assertdecoded=="user:pass"deftest_auth_modifies_prepared_request(self):s=requests.Session()req=requests.Request("GET","https://httpbin.org/headers")prep=s.prepare_request(req)auth=HTTPBasicAuth("testuser","testpass")prep=auth(prep)assert"Authorization"inprep.headers s.close()deftest_custom_auth_returns_modified_request(self):classDummyAuth(AuthBase):def__call__(self,r):r.headers["X-Auth-Dummy"]="yes"returnr s=requests.Session()s.auth=DummyAuth()resp=s.get("https://httpbin.org/headers")assertresp.json()["headers"]["X-Auth-Dummy"]=="yes"s.close()4. 项目总结
核心源码要点
| 类/函数 | 关键逻辑 | 复杂度 |
|---|---|---|
AuthBase | 抽象基类,单方法__call__(self, r) -> r | 低 |
HTTPBasicAuth | Base64 编码 user:pass(latin1 编码) | 低 |
HTTPDigestAuth | 解析 401 -> 计算 MD5 -> 构建 Digest 头 | 高 |
_basic_auth_str() | Base64 编码辅助函数 | 低 |
extract_cookies_to_jar() | 从 Response 提取 Cookie | 中 |
适用场景
- 理解 requests 认证体系的扩展机制
- 实现自定义认证协议(HMAC、OAuth2、AWS SigV4)
- 排查 401 重试和跨域认证剥离问题
- 学习 Challenge-Response 协议的设计模式
注意事项
- Basic Auth 使用 latin1 编码(非 UTF-8),中文用户名可能报错
- auth 在 Session.send() 中被调用,可能存在副作用
- 跨域重定向时 Authorization 头会被剥离(安全设计)
- HTTPDigestAuth 依赖 MD5——在现代安全标准中已不推荐
常见踩坑经验
案例一:Basic Auth 用户名含特殊字符。用户名中有冒号:,Base64 编码时user:pass中的:导致解析歧义——服务端解析出的用户名是user+ 冒号前的部分,密码包含了冒号后的所有内容。根因:username 中的:未被转义。修复:username 中的:替换为%3A,或避免在用户名中使用冒号。
案例二:OAuth2 Token 刷新时序竞争。多线程下 10 个请求同时检测到 Token 过期,触发 10 次刷新请求——认证服务器短暂过载。虽然加了线程锁,但_should_refresh()中的time.time() > self._expires_at在锁外判断,10 个线程都拿到了 True 然后排队等锁。根因:双重检查的第一次检查未完全防护。修复:在锁内再次检查。
思考题
源码题:阅读
auth.py中HTTPDigestAuth.build_digest_header()的源码,找出它是如何处理qop=auth-int(带请求体完整性保护的 qop)与qop=auth的差异的?设计题:设计一个
CompositeAuth——组合多个 AuthBase,按顺序依次修改 PreparedRequest。例如先注入 API Key,再计算 HMAC 签名(签名需要 API Key 作为输入)。如何保证顺序正确和独立可替换?
延伸阅读与资源
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析