feat(auth): v0.3.0 阶段1 收紧管理鉴权并分离管理/业务凭证

修一个前置缺口:RequireAdmin 此前在凭证列表为空时直接放行(len(keys)==0 -> next.ServeHTTP),导致未配置凭证的部署上管理接口完全裸奔,并且会让后续给 /metrics、/health 套该中间件的加固形同虚设。现改为拒绝(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 /api/settings 仅回打码值与来源)。

normal 模式下管理凭证为空时启动期 fail-fast:RequireAdmin 改为拒绝后,若此处不拦,服务会正常起来但 /dashboard 与全部 /api/admin/* 都是 401,等于把自己锁在门外。启动摘要同时打印管理凭证来源(admin_key / auth_key / 未配置)。

验证:新增 middleware/admin_auth_test.go 覆盖空凭证拒绝、正确/错误/缺前缀/空白 token、OPTIONS 放行,以及权限隔离的两个场景(配了 admin_key 时业务 key 401;回退模式下业务 key 200)。go build / go vet / go test ./... -count=1 全绿。
This commit is contained in:
sun
2026-10-04 00:42:32 +08:00
parent c4269086fa
commit d22e5f0801
7 changed files with 353 additions and 29 deletions
+17 -10
View File
@@ -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
}
+138
View File
@@ -0,0 +1,138 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/volcano-tts/tts-api/setting"
)
// okHandler 是被保护的假 handler。
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
}
// TestRequireAdmin_EmptyCredentialDenies 凭证未配置时必须拒绝,不能放行。
//
// 这是 v0.3.0 阶段 1 的核心修复:v0.3.0 之前 len(keys)==0 直接放行,
// 导致"没配 key"的部署上管理接口完全裸奔,也让后续给 /metrics、/health
// 套 RequireAdmin 的加固形同虚设。
func TestRequireAdmin_EmptyCredentialDenies(t *testing.T) {
t.Cleanup(func() { setting.SetAdminKeys(nil, "") })
setting.SetAdminKeys(nil, "")
h := RequireAdmin(okHandler())
// 完全不带 Authorization
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/api/admin/overview", nil))
if w.Code != http.StatusUnauthorized {
t.Errorf("无凭证 + 未配置 admin credential: code=%d, want 401", w.Code)
}
// 带任意 Bearer token 也必须拒绝(列表为空,没有合法 token 可言)
w2 := httptest.NewRecorder()
r2 := httptest.NewRequest("GET", "/api/admin/overview", nil)
r2.Header.Set("Authorization", "Bearer anything")
h.ServeHTTP(w2, r2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("任意 token + 未配置 admin credential: code=%d, want 401", w2.Code)
}
}
// TestRequireAdmin_ConfiguredCredentialEnforced 配了凭证后:命中放行、错误拒绝。
func TestRequireAdmin_ConfiguredCredentialEnforced(t *testing.T) {
t.Cleanup(func() { setting.SetAdminKeys(nil, "") })
setting.SetAdminKeys([]string{"admin-secret"}, "admin_key")
h := RequireAdmin(okHandler())
cases := []struct {
name string
authHeader string
wantStatus int
}{
{"正确凭证", "Bearer admin-secret", http.StatusOK},
{"错误凭证", "Bearer wrong-secret", http.StatusUnauthorized},
{"缺少 Bearer 前缀", "admin-secret", http.StatusUnauthorized},
{"空 Authorization", "", http.StatusUnauthorized},
{"仅空白 token", "Bearer ", http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/admin/overview", nil)
if c.authHeader != "" {
r.Header.Set("Authorization", c.authHeader)
}
h.ServeHTTP(w, r)
if w.Code != c.wantStatus {
t.Errorf("code=%d, want %d", w.Code, c.wantStatus)
}
})
}
}
// TestRequireAdmin_OptionsAlwaysAllowed OPTIONS 预检必须放行(浏览器预检不带 Authorization)。
func TestRequireAdmin_OptionsAlwaysAllowed(t *testing.T) {
t.Cleanup(func() { setting.SetAdminKeys(nil, "") })
setting.SetAdminKeys([]string{"admin-secret"}, "admin_key")
w := httptest.NewRecorder()
h := RequireAdmin(okHandler())
h.ServeHTTP(w, httptest.NewRequest("OPTIONS", "/api/admin/overview", nil))
if w.Code != http.StatusOK {
t.Errorf("OPTIONS: code=%d, want 200", w.Code)
}
}
// TestAdminAndBusinessCredentialIsolation 权限隔离:
// 配置了独立 admin_key 后,业务凭证(auth_key)不得访问管理接口;
// 未配置 admin_key 时回退,业务凭证仍可管理(向后兼容)。
func TestAdminAndBusinessCredentialIsolation(t *testing.T) {
t.Cleanup(func() {
setting.SetAdminKeys(nil, "")
setting.SetAuthAPIKeys(nil)
})
const businessKey = "business-key"
const adminKey = "admin-key"
// 业务侧凭证固定为 businessKey(模拟 /v1/audio/speech 用的 key)
setting.SetAuthAPIKeys([]string{businessKey})
// --- 场景 A:配置了独立 admin_key(隔离生效) ---
setting.SetAdminKeys([]string{adminKey}, "admin_key")
h := RequireAdmin(okHandler())
wBiz := httptest.NewRecorder()
rBiz := httptest.NewRequest("GET", "/api/admin/overview", nil)
rBiz.Header.Set("Authorization", "Bearer "+businessKey)
h.ServeHTTP(wBiz, rBiz)
if wBiz.Code != http.StatusUnauthorized {
t.Errorf("隔离生效时,业务 key 访问管理接口: code=%d, want 401", wBiz.Code)
}
wAdmin := httptest.NewRecorder()
rAdmin := httptest.NewRequest("GET", "/api/admin/overview", nil)
rAdmin.Header.Set("Authorization", "Bearer "+adminKey)
h.ServeHTTP(wAdmin, rAdmin)
if wAdmin.Code != http.StatusOK {
t.Errorf("隔离生效时,admin_key 访问管理接口: code=%d, want 200", wAdmin.Code)
}
// --- 场景 B:未配置 admin_key,回退用业务凭证(向后兼容) ---
setting.SetAdminKeys([]string{businessKey}, "auth_key")
wFallback := httptest.NewRecorder()
rFallback := httptest.NewRequest("GET", "/api/admin/overview", nil)
rFallback.Header.Set("Authorization", "Bearer "+businessKey)
h.ServeHTTP(wFallback, rFallback)
if wFallback.Code != http.StatusOK {
t.Errorf("回退模式下,业务 key 应可管理: code=%d, want 200", wFallback.Code)
}
}