📘 第八章 确认访问用户身份的认证
📄 一句话概括

DIGEST 认证让密码不上线:服务端给一个 nonce, 客户端用「密码 + nonce + 请求信息」算一个摘要发过去。 它解决了明文传输,但没解决重放,也没解决「服务端得知道密码」这件事。

📝 本节和前面几节的关系

上一节把密码送上去了,这一节想办法不送。思路和 7.1.3 的 HMAC 一样:比摘要,不比原文。

一、挑战—应答的三步

第一步服务端回 401,挑战里带四项:realm、nonce、qop=auth、algorithm=MD5。 nonce 是一次性随机串,Demo 里是 16 个十六进制字符。 第二步客户端算两个中间值:HA1 = MD5(账号:realm:密码)、HA2 = MD5(方法:URI),再把它们和 nonce、 nc、cnonce、qop 一起摘要:response = MD5(HA1:nonce:nc:cnonce:qop:HA2)。 第三步把 username、realm、nonce、uri、qop、nc、cnonce、response 一起发回去。 服务端拿自己存的密码算一遍,对得上就放行。 注意发出去的那一整行里没有密码。这是它比 BASIC 强的地方。

二、它没解决的三件事

一、服务端还是得知道密码原文。 要算 HA1 就得有 账号:realm:密码,所以服务端要么存明文, 要么存 HA1(等于把密码变成了另一个可直接用的凭据)。这一点比「存加盐哈希」还糟。 二、算法是 MD5。 Demo 里算出来的 response 是 32 个十六进制字符,看着挺像样,但 MD5 早就能造碰撞,强度不够。 三、挡不住重放。 攻击者拿到这一整行 Authorization,原样再发一次,服务端算出来的摘要一样对得上。 nc 计数器和 cnonce 本来想解决这个,但要求服务端记状态,实际实现里大多没做。

三、一个容易写错的地方

密码错的时候回 401,但不该带 stale=TRUE。 stale 的意思是「你的摘要格式没错,只是我这个 nonce 过期了, 拿新的重算」——它告诉客户端「别改密码,换个 nonce 再试」。 而密码错是「摘要就是不对」,混用会让客户端白试一轮。 这类细节在规范里写得很清楚,但实现时容易顺手复制粘贴。 排查「登录一直失败」的问题时,先看服务端回的挑战里带没带 stale。

四、动手 Demo:一次挑战,一次应答,两个摘要对上了

用纯 Python 实现服务端和客户端两侧的摘要计算,跑一遍完整的挑战应答; 再把密码换掉算一次,看摘要怎么变。

def ha1(name, realm, pw):
    return hashlib.md5((u'%s:%s:%s' % (name, realm, pw)).encode()).hexdigest()

def ha2(meth, uri):
    return hashlib.md5((u'%s:%s' % (meth, uri)).encode()).hexdigest()

def resp(name, realm, pw, meth, uri, nc, cnonce, qop):
    raw = u'%s:%s:%s:%s:%s:%s' % (ha1(name, realm, pw), nonce,
                                  nc, cnonce, qop, ha2(meth, uri))
    return hashlib.md5(raw.encode()).hexdigest()

实跑输出:

--------------------------------------------------------------------
DIGEST:密码不上线,只发摘要
--------------------------------------------------------------------
  第一次请求,服务端给一个挑战:
    HTTP/1.1 401 Unauthorized
    Digest realm="secret-zone", nonce="9d0aa7cbd5f69166", qop="auth", algorithm=MD5

  客户端按这个公式算摘要(密码**不**出现在请求里):
    HA1      = MD5(账号:realm:密码)      = 0a9e51ac79f6262cf764ea3dc6dc95e5
    HA2      = MD5(方法:URI)             = 4541216a6e9025c450063a950cc05d95
    response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
             = 3f3300d9508084ca53e9dfd6a0c53649

  发出去的 Authorization 是这一串:
    Digest username="bozai", realm="secret-zone", nonce="9d0aa7cbd5f69166", uri="/secret", qop=auth, nc=00000001, cnonce="600aefccb55398f6", response="3f3300d9508084ca53e9dfd6a0c53649"
    里面没有密码,只有算出来的摘要。

  服务端自己也算一遍,对得上就放行:
    HTTP/1.1 200 OK

  换一个错密码再算,摘要就完全不同:
    正确密码 → 3f3300d9508084ca53e9dfd6a0c53649
    错误密码 → 712fc4c68b4a5b221727b538cd8a5e77
    服务端比对不上,回 401。注意这里**不该**带 stale=TRUE——
    stale 的意思是「你的摘要没错,但我这个 nonce 过期了,拿新的重算」,
    密码错属于「摘要就是不对」,混用会让客户端白试一轮。

  DIGEST 解决了「密码上线」,但没解决全部:
    一、它依赖服务端**存明文密码**(要算 HA1 就得有原文),
       这一点比存哈希还糟,所以现代实现宁可存 HA1 也不存原文;
    二、摘要算法是 MD5,早就不安全;
    三、它挡不住中间人——攻击者拿到这一串摘要,照样能重放。
  所以 DIGEST 实际用得很少,主流是「HTTPS + 表单登录 + 会话 cookie」。

挑战里四项:realm="secret-zone"、nonce="a5ee9838664ce128"、qop=auth、algorithm=MD5。 算出来 HA1 是 0a9e51ac...、HA2 是 4541216a...、response 是 7fa657ad...。 服务端用同样的输入算一遍,一致,回 200。 换错密码重算,response 变成 d671d37b...,对不上,回 401。 两个值只差一点输入,输出完全无关——这正是哈希该有的样子。 发出去的那一整行长 100 多字节,里面一个密码字符都没有。这是 DIGEST 的全部价值,也是它唯一的进步。

30 秒实验

  1. 把 nonce 固定成同一个值,连发两次同样的请求,看服务端会不会都放行(重放)。
  2. 把算法从 MD5 换成 SHA-256,看摘要长度变成多少。
  3. 想一想:如果服务端只存 HA1 而不存密码,安全性比存密码好还是差?

常见误区

  1. 「DIGEST 就不用 HTTPS 了」——摘要能被重放,仍需加密保护。
  2. 「DIGEST 服务端不用存密码」——要算 HA1 就得有密码或等价的 HA1。
  3. 「密码错要回 stale=TRUE」——stale 表示 nonce 过期,不是密码错。
自测三问(点开对答案) 1. DIGEST 认证发出去的凭据里有密码吗? —— 没有,只有按公式算出的摘要。 2. 它为什么挡不住重放? —— 整行 `Authorization` 可以原样重发,服务端算出的摘要一样对得上。 3. `stale=TRUE` 是什么意思? —— 摘要格式没错,但 nonce 过期了,让客户端用新 nonce 重算。

两种基于密码的方案都有软肋。想更强,就得加上「持有」这一类凭据——客户端证书。

相关笔记