1. 先搞清楚我们到底在给什么加认证如果你自己搭过 Python 的临时 HTTP 服务一定见过这条命令python -m http.server 8000它能把当前目录变成一个可浏览下载的网站局域网里打开http://192.168.x.x:8000就能访问。问题也出在这里这个服务默认不设防只要有人扫到端口目录里所有文件都能被拉走。之前我用它给别人发资料结果没过多久就在日志里看到一堆来自陌生地址的探测请求才意识到内网也不是绝对安全。所以我养成了一个习惯不管多临时只要服务要开放给不是自己一个人用就先加上最简单的用户名密码认证。认证方式不需要复杂HTTP 协议自带的 Basic Auth 就够用。浏览器会自己弹登录框用户不需要记住任何额外链接也不需要配置客户端比“在 URL 里塞 token”这种野路子正规得多。这篇文章我会用三种方案实现同一件事基于标准库http.server的SimpleHTTPRequestHandler加认证适合静态文件共享基于BaseHTTPRequestHandler自定义请求处理适合你要自己写路由的 API 服务基于 WSGI 中间件配合waitress部署适合你打算把服务跑稳一点、以后要接正式环境的情况。你看完以后按自己的场景选一种抄走就行不用三份都读。我会把代码里的每个关键判断都解释清楚顺便把我踩过的坑一并说掉。1.1 默认的 http.server 为什么不行http.server是 Python 标准库内置的模块很多入门教程都拿它演示“一行代码开网站”。它底层调用socketserver默认监听0.0.0.0也就是说它会绑定本机所有网卡地址。在物理服务器上这等于所有能访问到这个主机的用户都能看到服务在个人电脑上哪怕你只连了公司局域网也会被同一个 WiFi 下的人扫到。最直接的问题是没有认证。SimpleHTTPRequestHandler只会忠实地把磁盘文件映射成 URL任何人发起 GET 请求都能拿到内容。它对HEAD、GET、POST的处理也很简单HEAD只返回头信息GET返回文件内容POST默认直接报 501。这种设计本来就不是给生产环境用的它连基本的访问控制接口都没暴露出来。并不是说它没用而是说你应该在它外面加一层“门禁”。加认证的本质是在请求真正进入目录映射逻辑之前先检查Authorization请求头里的凭据是否正确。只要校验没通过直接返回 401并告诉浏览器去弹登录框。1.2 Basic Auth 的原理和适用范围Basic Auth 是 HTTP/1.0 时代就定下的认证方式。它的流程非常简单客户端第一次请求时通常没有Authorization头服务端发现没有这个头返回401 Unauthorized并在响应头里带上WWW-Authenticate: Basic realmxxx浏览器的登录框读取 realm 作为提示文字用户输入用户名密码浏览器用冒号把用户名和密码拼成username:password再做一次 Base64 编码放到请求头里发回来服务端解出这一段比对用户名密码通过就继续处理请求不通过就再次返回 401。这里要强调一点Base64 不是加密它只是一种可逆的编码形式。把admin:123456编码成一串字符随便找个在线工具就能倒推回来。所以 Basic Auth 只在局域网、内网或者有 HTTPS 加密的前提下算“能用”。它在公网上裸奔等于把密码明文交给网络里的任何中间设备。1.3 三种方案的适用场景对比我先把三种方案的适用场景列成表格方便你快速定位。方案依赖适合场景代码量维护成本方案一SimpleHTTPRequestHandler标准库共享静态文件、临时下载页少低方案二BaseHTTPRequestHandler标准库自写 API、自定义路由、网关式服务中中方案三WSGI waitresswaitress、werkzeug正式部署、多线程、后续要接 WSGI 应用中中如果只是给前端打包出来的页面做本地预览方案一足够如果是给用户提供一个带登录态的接口服务方案二更好如果你已经习惯了 Flask 那种app.route的写法又不想被框架绑定方案三可以让你用纯 WSGI 方式搭服务后面换成 Gunicorn 也没压力。2. 方案一给静态文件服务器套上认证方案一改造的是http.server里最常用的SimpleHTTPRequestHandler不需要引入任何第三方库适合python -m http.server这种一键启动场景。我实际的使用场景一般是临时把某个目录开放给同事下载资料、给项目组共享构建产物、或者在自己电脑上预览一个静态站点。这类需求最看重的就是“改得少、跑得快”。2.1 手写 Authorization 校验函数我们需要一个函数从请求头里取出Authorization判断它是不是 Basic Auth并把 Base64 解码后的用户名密码拆出来比对。import base64 USERNAME admin PASSWORD secret123 def is_authorized(headers): auth headers.get(Authorization, ) if not auth.startswith(Basic ): return False try: payload base64.b64decode(auth[6:]).decode(utf-8) username, _, password payload.partition(:) except Exception: return False return username USERNAME and password PASSWORD我特意加了try/except因为你不能假设客户端每次发的都是合法 Base64。有人用脚本扫描时Authorization头可能是一些乱写的值比如Basic !或者直接把 Token 放在 Bearer 头里。一旦base64.b64decode解不出来我们的代码不能像没看见一样继续往下面走而是统一当作未认证处理。partition(:)返回一个三元组这里只需要用户名和密码。注意用户名里不能有冒号因为协议约定第一个冒号是分隔符密码里如果有冒号反而没事partition只按第一次出现的位置拆分。2.2 用 send_head 统一拦截所有请求接下来新建一个请求处理类继承SimpleHTTPRequestHandler并重写send_head。为什么要重写这个方法而不是do_GET因为SimpleHTTPRequestHandler的do_GET和do_HEAD最后都会调用send_head去构造响应所以在这里拦截等于一次性覆盖了 GET 和 HEAD 两种请求。from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer class AuthHandler(SimpleHTTPRequestHandler): def send_head(self): if not is_authorized(self.headers): self.send_response(401) self.send_header( WWW-Authenticate, Basic realmprivate-files ) self.send_header(Content-Length, 0) self.end_headers() return None return super().send_head()如果is_authorized返回 False我们直接返回 401 和一个WWW-Authenticate头。realm是登录框上显示的提示信息浏览器会根据它缓存同一套凭据。如果你只有一个服务这个值写什么都行如果你在同一站点可能部署多个独立认证区域realm 要小心区分否则浏览器会混用凭据。返回None是合法的因为do_GET拿到send_head的返回值之后只有返回值是真值时才继续写文件内容否则就直接结束连接。写一个空 body 的 401 响应既不会让恶意请求拿走任何文件也不会浪费带宽。最后加上启动入口def main(): server ThreadingHTTPServer((0.0.0.0, 8000), AuthHandler) print(serve on 0.0.0.0:8000) server.serve_forever() if __name__ __main__: main()我用ThreadingHTTPServer而不是HTTPServer是因为它每来一个连接就开一个线程处理遇到多个浏览器同时下载文件时不会互相阻塞。Python 3.7 之后都内置了这个类不需要额外装线程库。如果你只是在自己电脑上临时跑一下用哪个区别不大。2.3 用命令行参数动态配置用户名密码把用户名密码写死在代码里有个问题换一次密码就要改代码。对临时服务来说这其实挺烦的。所以我会在启动脚本里加上argparseimport argparse parser argparse.ArgumentParser() parser.add_argument(--username, defaultadmin) parser.add_argument(--password, defaultsecret123) parser.add_argument(--port, typeint, default8000) parser.add_argument(--directory, default.) args parser.parse_args()启动时就可以这样用python auth_server.py --username alice --password pss --port 9000 --directory ./share这里有个容易踩的坑千万不要把密码直接写在命令行历史里尤其当你在共享机器上操作时。临时用用可以正式一点的话把密码放到环境变量或者本地配置文件里脚本里读取os.environ的值这样命令历史里就不会留下明文密码。用环境变量还有个好处后面配合 Docker 或 systemd 部署时不用改代码只改环境变量就能换密码。3. 方案二BaseHTTPRequestHandler 打造自定义 API 认证如果你不只是想共享文件而是想提供一个带认证的 JSON 接口那么SimpleHTTPRequestHandler就不够灵活了。它把目录映射逻辑写死了你想加一个/api/status路由、想区分 GET 和 POST、想返回 JSON 而不是文件内容都得费劲绕过去。这时候应该直接继承BaseHTTPRequestHandler把请求处理的逻辑握在自己手里。3.1 一个带路由的最小认证服务器继承BaseHTTPRequestHandler之后你需要自己实现do_GET、do_POST等方法。认证检查可以写成一个私有方法在所有入口统一调用。下面是去掉文件服务、只返回 JSON 的最小示例import base64 import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer USERNAME alice PASSWORD pssw0rd class AuthAPIHandler(BaseHTTPRequestHandler): def _check_auth(self): auth self.headers.get(Authorization, ) if not auth.startswith(Basic ): return False try: raw base64.b64decode(auth[6:]).decode(utf-8) user, _, pwd raw.partition(:) except Exception: return False return user USERNAME and pwd PASSWORD def _send_401(self): self.send_response(401) self.send_header(WWW-Authenticate, Basic realmapi) self.send_header(Content-Type, application/json; charsetutf-8) body json.dumps({error: authentication required}).encode(utf-8) self.send_header(Content-Length, str(len(body))) self.end_headers() self.wfile.write(body) def _send_json(self, data, status200): body json.dumps(data).encode(utf-8) self.send_response(status) self.send_header(Content-Type, application/json; charsetutf-8) self.send_header(Content-Length, str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if not self._check_auth(): self._send_401() return if self.path /api/ping: self._send_json({message: pong}) elif self.path /api/time: self._send_json({now: 2025-01-01T00:00:00}) else: self._send_json({error: not found}, status404) def log_message(self, fmt, *args): print(f{self.address_string()} - {fmt % args})注意我在 401 响应里设置了Content-Length为 JSON body 的长度而不是直接调send_error。send_error会返回 HTML 错误页对前后端分离的项目不太友好。如果你要做一个纯 JSON API最好统一用_send_json这种方式控制响应格式。前端拿到 401 后只要看到响应头里有WWW-Authenticate就能触发浏览器的登录弹窗。3.2 给接口加 POST 和错误处理很多入门项目只实现了do_GET结果前端用fetch发POST请求时直接得到 501。继承BaseHTTPRequestHandler后所有方法的默认实现都是返回 501所以如果你不实现do_POST它就会报“Unsupported method”。如果你要接收 JSON记得手动读取并解析 body。class AuthAPIHandler(BaseHTTPRequestHandler): def do_POST(self): if not self._check_auth(): self._send_401() return try: length int(self.headers.get(Content-Length, 0)) payload self.rfile.read(length) data json.loads(payload.decode(utf-8)) except Exception: self._send_json({error: bad json}, status400) return if self.path /api/submit: self._send_json({received: data}) else: self._send_json({error: not found}, status404)需要留意的是这里的Content-Length是用字符串形式接收的布尔判断时0是 True所以要用int()转换。另外self.rfile.read(length)若客户端没有正确的 Content-Length 头可能