feat(auth)!: v0.3.0 阶段一 鉴权收紧 + 健康/指标端点收口

本提交由初版方案的「阶段 1」与「阶段 2」合并而成。合并原因:两者是同一件事的两半——
只收紧鉴权而不收口端点,结果是"有风险无收益"(未配凭证的旧部署可能起不来,
而 /health、/dashboard、/metrics 仍匿名可读)。

一、鉴权收紧(原阶段 1)
- RequireAdmin 在凭证列表为空时不再放行。此前 len(keys)==0 直接 next.ServeHTTP,
  导致未配凭证的部署上管理接口完全裸奔,也让后续给端点加鉴权的加固形同虚设。
  现在该情况返回 401 并记录明确日志。
- 新增可选独立管理凭证 admin_key,取值优先级:
  admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。
  middleware.ValidateAPIKey(业务侧 /v1/audio/speech)保持只看 auth_key,
  于是配置 admin_key 后业务调用方持有的 key 无法访问管理接口,权限隔离成立;
  不配置则回退 auth_key,老部署行为不变。
  新增 PUT /api/admin/settings/admin-key 与旧前缀别名,
  凭证只写不读(GET 仅返回打码值与 admin_key_set / admin_key_source)。
- normal 模式下管理凭证为空时启动期 fail-fast。RequireAdmin 改为拒绝后,
  若此处不拦,服务会正常起来但 /dashboard 与全部 /api/admin/* 都是 401,
  等于把自己锁在门外;宁可启动失败并打印明确原因。

二、端点收口(原阶段 2)
- /health 改为鉴权(原匿名泄漏版本、commit、运行时长、内存、goroutine、配置错误文本)
- /dashboard 改为鉴权,并对浏览器 HTML 请求做内容协商(Accept 含 text/html 时
  返回页面外壳,由前端据 sessionStorage 显示登录视图;非 HTML 请求无凭证一律 401)。
  不做无条件 401 的原因:SPA 登录态存在 sessionStorage、不随请求发送,
  服务端无从判断是否已登录,强行 401 会把"打开看到登录页"变成"打开直接报错"。
  页面外壳不含任何数据,数据全部来自鉴权后的 API。
- /api/setup/status 改为鉴权(原本 normal 模式下仍匿名返回 installed/mode)
- 根路径 /metrics 默认完全不注册(访问 404);配置 METRICS_ALLOW_CIDR 后按内网
  白名单开放,非白名单返回 404 而非 403,不向扫描者确认端点存在。
- 新增 GET /healthz 匿名存活探针,只回 200 与字面量 ok、不含任何字段,解决
  "给 /health 加鉴权后 K8s 探针与 Docker HEALTHCHECK 会一律 401 导致 Pod 反复重启";
  新增 GET /api/admin/health 鉴权版详细健康数据。

IP 白名单中间件复用 GetClientIP(已处理 XFF 与 TRUSTED_PROXY_HOPS);
main.go 启动日志同步改为指向 /healthz 与 /api/admin/health。

BREAKING CHANGE:
1. /health、/dashboard、/api/setup/status 不再匿名可读;根路径 /metrics 需配
   METRICS_ALLOW_CIDR,否则 404。
2. 未配置 auth_key / admin_key / OPENAI_TTS_API_KEY 任一时,normal 模式下服务拒绝启动。
3. K8s 探针与 Docker HEALTHCHECK 必须改指 /healthz;Prometheus 请改用鉴权版
   /api/admin/metrics 或配置 METRICS_ALLOW_CIDR(Docker 中勿填 127.0.0.1/32,
   那是容器自身回环,应填容器内网网段)。

验证:go build / go vet / go test ./... -count=1 全绿(7 个包);
另用真实服务器端到端验证端点矩阵、内容协商、凭证隔离与 CIDR 白名单两种形态。
This commit is contained in:
sun
2026-10-04 01:17:06 +08:00
parent 49f57e4d28
commit c18cf63518
8 changed files with 474 additions and 56 deletions
+15
View File
@@ -274,6 +274,21 @@ func contentTypeFor(format string) string {
}
// HealthHandler 暴露运行期状态;无鉴权。
// HealthzHandler GET /healthz —— 匿名存活探针,**只回 200 与字面量 "ok"**。
//
// 为什么单独做这个:v0.3.0 把详细健康数据(/health)收口到管理鉴权之后,
// 但 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查默认都不带 Authorization。
// 若把它们继续指向 /health,加鉴权后会一律 401,导致探针失败、Pod 反复重启。
//
// 因此本端点刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式信息),
// 只用于回答"进程还在不在"。运维要细节请走鉴权后的 /health。
func HealthzHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")