3、HTTP摘要认证:Digest Auth 流程、nonce 机制、与 Basic 对比、实战配置
说实话,Basic 认证虽然简单,但有一个硬伤——密码几乎是明文传输的。只要中间人抓个包,Base64 解码一下,你的用户名密码就全裸了。我在几年前负责一个对内管理系统时,一开始图省事用了 Basic Auth,结果安全扫描直接标红。嗯,从那以后我就老老实实把方案改成了 Digest。
那 Digest Auth 到底怎么解决这个问题的?说白了就是“不传密码本身,传密码的哈希结果”,而且每次的哈希结果都不一样。听起来很神奇?我们一步步拆开看。
摘要认证是怎么来的?
HTTP 摘要认证(Digest Access Authentication)由 RFC 7616 定义。它的核心思路是:客户端用密码加上服务器给的“随机因子”做一个单向哈希,服务器端再做同样的计算,比对两边结果。这样密码永远不会在网络中裸奔。
我个人习惯把这个过程想象成“对暗号”:服务器先给一个随机挑战,客户端拿密码去解,解对了才能进门。暗号一次一换,就算被人偷听到也没用。
Digest Auth 的核心流程
整个握手过程比 Basic 多了一轮。看下面这张图会更清楚:
具体步骤是这样的:
- 客户端发请求,啥都不带。
- 服务器返回
401 Unauthorized,头里带着WWW-Authenticate字段,里面包含realm(领域)、nonce(随机数)、opaque(可选)、qop(保护质量)。 - 客户端拿着这些值,加上用户密码,算出一个
response,放在Authorization头里再发一次。 - 服务器用同样的方式重新算一遍,跟传过来的
response比对。一致就通过,返回200 OK。
nonce 机制——防重放攻击的关键
你可能会问:如果我抓到了某个请求的 response,能不能直接重放?答案是不行。因为 nonce 是一次性的,服务器每次都会生成一个新值。
我在项目中遇到过这样一个场景:有个设备需要定时上报数据,本来用的 Basic Auth,后来发现有人拿以前的请求包重复发送,伪造了数据。换成 Digest 之后,nonce 绑定了时间戳和客户端 IP,服务器只认一次。重放攻击直接失效。
nonce 生成规则建议:
- 可以用
base64(时间戳 + 随机字符串 + 私钥) - 服务端要记录已使用的 nonce,防止重复使用
- 设置有效期,比如 60 秒,过期后必须重新获取
Digest vs Basic:一张表说清楚
| 对比维度 | Basic Auth | Digest Auth |
|---|---|---|
| 密码传输 | Base64 编码(可逆) | MD5/SHA-256 哈希(不可逆) |
| 防重放 | 完全不防 | 依赖nonce+计数,强 |
| 复杂度 | 极低,一行头 | 中等,需要计算两次哈希 |
| 服务端存储 | 存明文密码或哈希 | 需存密码原文(用于计算HA1),或HA1缓存 |
| 适用场景 | 内网工具、快速原型 | 对安全有要求的Web API、IoT |
注意: 别再问我 Digest Auth 是不是安全了。它只能防传输过程中的密码泄露,防不了中间人篡改或 TLS 缺失。所以实际生产环境,Digest 一定要跑在 HTTPS 上。
实战配置:用 Python 实现 Digest Auth
我习惯用 requests 库,它原生支持 Digest Auth。不过默认只会处理一次挑战,如果你的服务端 nonce 过期了,需要自己实现重试逻辑。
import requests
from requests.auth import HTTPDigestAuth
url = "https://api.example.com/data"
username = "admin"
password = "secret123"
# 第一次请求 —— 会自动处理401挑战,计算出response
resp = requests.get(url, auth=HTTPDigestAuth(username, password))
print(resp.status_code, resp.text)
但有一次我遇到一个坑:服务器返回的 WWW-Authenticate 里带了 qop=auth-int,而 requests 默认不支持这种模式。当时我手动拆了返回头,自己拼哈希才搞定。嗯,这里分享一个手动实现的简化版思路:
import hashlib
def calc_response(method, uri, realm, nonce, username, password, qop="auth"):
# HA1 = MD5(username:realm:password)
ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
# HA2 = MD5(method:uri)
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
# response = MD5(HA1:nonce:HA2) (qop=auth 时)
response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()
return response
# 用法示例
nonce = "deadbeef1234"
realm = "My API"
uri = "/api/data"
method = "GET"
resp_val = calc_response(method, uri, realm, nonce, "admin", "secret123")
print(resp_val)
我的习惯: 在服务端,我从不存密码原文,而是存 HA1 (MD5(username:realm:password))。这样即使数据库被拖,也拿不到密码。但要小心,realm 变了 HA1 就失效了,所以 realm 一般固定。
避坑指南
- 我曾经以为 Digest 就不需要 HTTPS 了 —— 这是大错特错的。没有 TLS,nonce 和 realm 都是明文,中间人可以伪造挑战,诱导客户端用弱密码计算响应。
- nonce 过期时间别设太长。我见过有服务设 24 小时的,等于给攻击者敞开了大门。一般 30~60 秒比较合适。
- qop 参数尽量用 auth,别用 auth-int,因为 auth-int 要对整个 body 做哈希,性能差,实现也容易 buggy。
- 密码里的特殊字符 比如冒号,在拼接 HA1 时要注意转义。RFC 2617 里说密码中的冒号保留原样,但很多老库处理不对。
好了,这一章的内容就到这。Digest Auth 虽然比 Basic 安全了一个档次,但它仍然不是银弹。下一类更现代的方案——比如 和 JWT,我们后面再聊。不过别着急,先把 Digest 玩熟,很多老系统的认证改造都靠它。