HTTP Basic 认证把编码后的用户名与密码放进 Authorization 请求头。由于编码可逆,取得该请求头的值,往往就相当于取得凭据本身。Basic 确实定义了认证挑战与响应流程,但不会用密码学方式把响应绑定到服务器刚生成的一次性随机数。只要服务器继续接受这个账号和凭据,一份曾被接受的值就可能被再次使用。不能把“存在挑战流程”误解成“重放一定失败”。

看清真正发生的交换

服务器可以返回四零一,并通过 WWW-Authenticate: Basic 挑战声明 realm,客户端再带 Authorization 重试。客户端也可以在合适的保护空间内提前发送凭据。因此,带认证的请求会携带凭据,不意味着每个最初请求一开始就必须有它。realm 用于标识保护空间,并不会自动授予同名路径上的所有权限,更不能替代具体的资源访问控制。

编码输入由用户标识、一个冒号和密码拼接。第一个冒号是分隔符,所以用户名不能包含冒号,密码则可以包含后续冒号。用户名和密码都不能包含控制字符;允许密码里出现冒号,并不意味着它可以包含任意字符。Base64 既不是加密,也不是密码哈希。涉及非 ASCII 凭据时还要考虑字符编码:RFC 7617 在兼容 ASCII 的条件下没有统一默认编码,并允许服务器挑战指明 UTF-8。仅验证一个 ASCII 示例,不等于已经覆盖所有国际化账号的编码与比较路径。

Basic 本身不定义会话令牌或通用退出登录协议。应用仍可以设置账号到期、禁用账号、改变授权或拒绝旧密码。因此,轮换密码并不是阻止泄露请求头继续生效的唯一办法。凭据的有效期与撤销方式,应依据服务器真实的账号策略定义,不能期待网络传输格式自动提供完整账号生命周期管理。客户端停止发送,与服务器已经拒绝旧值,也是不同事实。

坏掉的样子

在没有保护的网络链路上,能够读取请求头的人可以解码并再次使用。正确校验证书的 HTTPS 保护的是到 TLS 终点的传输,不会让终点本身、合法终止 TLS 的代理或疏忽的应用日志看不到凭据。还需要保护后续链路,并避免在日常追踪、报告和共享诊断文件中记录 Authorization 的真实值。传输加密不能替代服务端对凭据的妥善处理。

权限过大的凭据会扩大泄露后果。账号应匹配具体任务及其生命周期,身份验证之后仍要执行授权。凭据有效,不等于每个操作都允许;realm 标签也不是租户隔离机制。客户端需要限制凭据发送位置,尤其关注重定向和脚本复用全局请求头的情况。把同一个认证头传给无关目的地址,超出了原来的保护空间,也破坏了凭据使用边界。

重复的四零一可能来自凭据缺失、错误、到期或服务端不支持相应方式。在下面例子中,四零三表示已经识别的演示账号缺少目标权限,但不能据此认为所有服务返回四零三都证明认证已成功。应结合服务契约及响应中的认证挑战检查,不能仅凭一个数字推断完整的身份状态。认证结果和业务许可,应分别提供能够判断的证据。

快速验证

保存为 basic-auth.py,执行 python3 basic-auth.py。所有凭据都是刻意编造的演示值:用户名为 DEMO,密码为 NOT:A:SECRET。服务器只绑定回环地址,用明文 HTTP 展示协议机制;不要把这些值替换成真实凭据。实验没有测试 TLS、持久账号存储或生产认证服务,也不会读取当前环境的登录信息。

from base64 import b64decode, b64encode
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import hmac
import json

synthetic = b'DEMO:NOT:A:SECRET'
authorization = 'Basic ' + b64encode(synthetic).decode('ascii')


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        accepted = hmac.compare_digest(self.headers.get('Authorization', '').encode('latin1'),
                                       authorization.encode('ascii'))
        status = 401 if not accepted else 403 if self.path == '/admin' else 200
        data = {401: b'credentials required\n', 403: b'permission denied\n', 200: b'allowed\n'}[status]
        self.send_response(status)
        if status == 401:
            self.send_header('WWW-Authenticate', 'Basic realm="local-demo", charset="UTF-8"')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
    observed = []
    for path, credential in [('/', None), ('/', authorization), ('/', authorization),
                             ('/admin', authorization), ('/', 'Basic invalid')]:
        connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
        try:
            connection.request('GET', path, headers={'Authorization': credential} if credential else {})
            response = connection.getresponse()
            observed.append({'status': response.status,
                             'challenge': response.getheader('WWW-Authenticate'),
                             'body': response.read().decode()})
        finally:
            connection.close()
    assert [r['status'] for r in observed] == [401, 200, 200, 403, 401]
    assert observed[0]['challenge'] == 'Basic realm="local-demo", charset="UTF-8"'
    user, password = b64decode(authorization.split()[1], validate=True).split(b':', 1)
    assert user == b'DEMO' and password == b'NOT:A:SECRET'
    print(json.dumps({'responses': observed, 'synthetic_decoded_user': user.decode(),
                      'synthetic_decoded_password': password.decode(), 'replay_accepted': True}))
finally:
    server.shutdown()
    thread.join()
    server.server_close()

第一次请求得到四零一和 Basic 挑战。随后使用完全相同的编码值发送两次请求,都得到二百,说明凭据继续有效时重放可以被接受。演示账号访问 /admin 得到四零三,无效认证头则再次得到四零一。服务端关闭了常规请求日志,不会记录这些请求头;输出只展示事先明确说明的合成值与相应结果。

最终断言对合成凭据解码,并只在第一个冒号处分割,后面的冒号都属于密码。这验证了可逆编码、挑战发送、重复接受以及独立的权限判断。处理器只识别一个固定演示值,不是完整认证系统。生产审阅仍需把凭据存储、请求节流、撤销、传输与授权放在一起检查,不能将这个局部示例当作已经具备全部账号安全能力的实现。

延伸阅读

来源