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 realm="xxx"; - 浏览器的登录框读取 realm 作为提示文字,用户输入用户名密码;
- 浏览器用冒号把用户名和密码拼成
username:password,再做一次 Base64 编码,放到请求头里发回来; - 服务端解出这一段,比对用户名密码,通过就继续处理请求,不通过就再次返回 401。
这里要强调一点:Base64 不是加密,它只是一种可逆的编码形式。把admin:123456编码成一串字符,随便找个在线工具就能倒推回来。所以 Basic Auth 只在局域网、内网或者有 HTTPS 加密的前提下算“能用”。它在公网上裸奔,等于把密码明文交给网络里的任何中间设备。
1.3 三种方案的适用场景对比
我先把三种方案的适用场景列成表格,方便你快速定位。
| 方案 | 依赖 | 适合场景 | 代码量 | 维护成本 |
|---|---|---|---|---|
| 方案一:SimpleHTTPRequestHandler | 标准库 | 共享静态文件、临时下载页 | 少 | 低 |
| 方案二:BaseHTTPRequestHandler | 标准库 | 自写 API、自定义路由、网关式服务 | 中 | 中 |
| 方案三:WSGI + waitress | waitress、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 realm="private-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 用命令行参数动态配置用户名密码
把用户名密码写死在代码里有个问题:换一次密码就要改代码。对临时服务来说,这其实挺烦的。所以我会在启动脚本里加上argparse:
import argparse parser = argparse.ArgumentParser() parser.add_argument("--username", default="admin") parser.add_argument("--password", default="secret123") parser.add_argument("--port", type=int, default=8000) parser.add_argument("--directory", default=".") args = parser.parse_args()启动时就可以这样用:
python auth_server.py --username alice --password p@ss --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 = "p@ssw0rd" 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 realm="api"') self.send_header("Content-Type", "application/json; charset=utf-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, status=200): body = json.dumps(data).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-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"}, status=404) 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"}, status=400) return if self.path == "/api/submit": self._send_json({"received": data}) else: self._send_json({"error": "not found"}, status=404)需要留意的是这里的Content-Length是用字符串形式接收的,布尔判断时"0"是 True,所以要用int()转换。另外self.rfile.read(length)若客户端没有正确的 Content-Length 头,可能