feat(auth)!: v0.2.4 阶段一 鉴权收紧 + 健康/指标端点收口
本提交由初版方案的「阶段 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:
+17
-10
@@ -9,14 +9,19 @@ import (
|
||||
"github.com/volcano-tts/tts-api/setting"
|
||||
)
|
||||
|
||||
// RequireAdmin 是 /admin 路由的鉴权中间件,复用 OPENAI_TTS_API_KEY。
|
||||
// 行为:
|
||||
// - Auth.APIKeys 为空 → 所有请求放行(等同无鉴权)
|
||||
// - Authorization 头 Bearer token 在列表中 → 放行
|
||||
// - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'}
|
||||
// RequireAdmin 是管理接口(/api/admin/*、/api/voices*、/api/settings*)的鉴权中间件。
|
||||
//
|
||||
// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys,
|
||||
// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。
|
||||
// 凭证来源:setting.GetAdminKeys(),优先级 admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。
|
||||
// 与业务侧鉴权(middleware.ValidateAPIKey,只看 auth_key)分离,实现权限隔离:
|
||||
// 配置了独立 admin_key 后,业务调用方持有的 key 无法访问管理接口。
|
||||
//
|
||||
// 行为:
|
||||
// - OPTIONS 预检 → 放行(浏览器预检不带 Authorization)
|
||||
// - 未配置任何凭证 → **拒绝**(401)。这是刻意设计:v0.3.0 之前这里直接放行,
|
||||
// 导致管理接口在"没配 key"的部署上完全裸奔,并让后续给 /metrics、/health
|
||||
// 套本中间件的加固形同虚设。启动期已由 main.go 做 fail-fast 校验。
|
||||
// - Authorization 头 Bearer token 命中凭证列表 → 放行
|
||||
// - 其它 → 401 + JSON {error: {code: 'admin_auth_failed'}}
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
|
||||
@@ -25,10 +30,12 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
keys := setting.GetAuthAPIKeys()
|
||||
keys := setting.GetAdminKeys()
|
||||
if len(keys) == 0 {
|
||||
// 没配 admin key,等同无鉴权
|
||||
next.ServeHTTP(w, r)
|
||||
// 没配管理凭证 → 拒绝(旧行为是放行,见上方注释说明为何改掉)
|
||||
log.Printf("[admin_auth] 拒绝:未配置管理凭证(admin_key/auth_key/OPENAI_TTS_API_KEY 均为空) - 路径=%s 客户端=%s",
|
||||
r.URL.Path, GetClientIP(r))
|
||||
denyAdmin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// metricsAllowList 是 METRICS_ALLOW_CIDR 解析出的内网白名单。
|
||||
// 启动期由 InitMetricsAllowList 填充;运行期只读,无并发写。
|
||||
var metricsAllowList []*net.IPNet
|
||||
|
||||
// metricsAllowConfigured 表示是否配置了非空的 METRICS_ALLOW_CIDR。
|
||||
// 决定根路径 /metrics、/health 是否注册(未配置则完全不注册,访问 404)。
|
||||
var metricsAllowConfigured bool
|
||||
|
||||
// InitMetricsAllowList 解析 METRICS_ALLOW_CIDR,启动期调用一次。
|
||||
//
|
||||
// 格式:逗号分隔的 CIDR 列表,例如 "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"。
|
||||
// 也接受裸 IP(自动补 /32 或 /128),方便写 "127.0.0.1"。
|
||||
//
|
||||
// 为什么需要它:根路径 /metrics、/health 是给 Prometheus 抓取 / K8s 探针用的机器端点,
|
||||
// 让它们带 Bearer 会强制改抓取配置;用内网白名单更省事,且默认不暴露。
|
||||
//
|
||||
// 【重要】Docker 环境不要填 127.0.0.1/32 —— 那是容器自身的回环,
|
||||
// Prometheus 在宿主机或另一个容器里根本进不来。请填容器内网网段(如 172.16.0.0/12)。
|
||||
func InitMetricsAllowList() {
|
||||
raw := strings.TrimSpace(os.Getenv("METRICS_ALLOW_CIDR"))
|
||||
metricsAllowList = nil
|
||||
metricsAllowConfigured = false
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// 裸 IP 自动补全掩码
|
||||
if !strings.Contains(entry, "/") {
|
||||
if ip := net.ParseIP(entry); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
entry += "/32"
|
||||
} else {
|
||||
entry += "/128"
|
||||
}
|
||||
}
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(entry)
|
||||
if err != nil {
|
||||
log.Printf("[metrics-ip] 忽略非法 CIDR 条目 %q: %v", part, err)
|
||||
continue
|
||||
}
|
||||
metricsAllowList = append(metricsAllowList, ipnet)
|
||||
}
|
||||
metricsAllowConfigured = len(metricsAllowList) > 0
|
||||
if metricsAllowConfigured {
|
||||
log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 已启用,根路径 /metrics 仅对 %d 个网段开放", len(metricsAllowList))
|
||||
} else {
|
||||
log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 无有效条目,根路径 /metrics 将不注册")
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsAllowListConfigured 报告是否配置了有效的白名单网段。
|
||||
// router 据此决定是否注册根路径 /metrics / /health。
|
||||
func MetricsAllowListConfigured() bool { return metricsAllowConfigured }
|
||||
|
||||
// MetricsIPAllowList 只放行来源 IP 命中白名单的请求。
|
||||
//
|
||||
// 未命中返回 **404**(而不是 403):不向扫描者确认"这里存在一个只是你没权限的端点"。
|
||||
// 客户端 IP 取自 GetClientIP,它已处理 X-Forwarded-For 与 TRUSTED_PROXY_HOPS,
|
||||
// 所以反代后面的真实来源也能正确判定。
|
||||
func MetricsIPAllowList(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !ipAllowed(GetClientIP(r)) {
|
||||
// 不打印每个被拒请求,避免扫描流量刷爆日志;只按需在 debug 下输出
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ipAllowed 判定客户端 IP 是否命中任一白名单网段。
|
||||
// 空 IP(解析失败)一律拒绝 —— 宁可拒绝也不能误放行。
|
||||
func ipAllowed(clientIP string) bool {
|
||||
if clientIP == "" {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(clientIP))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range metricsAllowList {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user