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:
@@ -4,6 +4,29 @@
|
|||||||
|
|
||||||
## [未发布]
|
## [未发布]
|
||||||
|
|
||||||
|
### v0.3.0 · 进行中
|
||||||
|
|
||||||
|
按 [docs/IMPLEMENT_v0.3.0.md](docs/IMPLEMENT_v0.3.0.md) 分阶段实施,当前完成 **阶段 1**。
|
||||||
|
|
||||||
|
#### 新增
|
||||||
|
|
||||||
|
- **独立管理凭证 `admin_key`(可选)**:管理接口凭证与业务调用凭证分离。
|
||||||
|
取值优先级 `admin_key`(DB) → `auth_key`(DB) → `OPENAI_TTS_API_KEY`(env)。
|
||||||
|
**不配置时行为与旧版完全一致**(回退用 `auth_key`),配置后业务 key 无法访问管理接口。
|
||||||
|
新增 `PUT /api/admin/settings/admin-key`(及旧前缀别名 `/api/settings/admin-key`)用于配置,
|
||||||
|
凭证**只写不读**(`GET /api/settings` 仅返回打码值与 `admin_key_set` / `admin_key_source`)。
|
||||||
|
|
||||||
|
#### 变更(Breaking Change)
|
||||||
|
|
||||||
|
- **`RequireAdmin` 在凭证未配置时不再放行**。此前 `len(keys)==0` 直接放行,导致未配置凭证的
|
||||||
|
部署上管理接口完全裸奔(也使得给 `/metrics`、`/health` 加鉴权的加固形同虚设)。
|
||||||
|
现在该情况返回 **401**。
|
||||||
|
- **normal 模式下未配置任何管理凭证时服务拒绝启动**(fail-fast),并在启动摘要中打印
|
||||||
|
管理凭证来源。此前会正常启动但后台实际无保护。
|
||||||
|
|
||||||
|
> ⚠️ **升级提示**:若你的部署当前未配置 `auth_key` / `admin_key` / `OPENAI_TTS_API_KEY`
|
||||||
|
> 中的任何一个,升级到本版本后服务将拒绝启动。请先配置其中之一。
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
- **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过
|
- **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ type SettingsResponse struct {
|
|||||||
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
|
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
|
||||||
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
|
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
|
||||||
AuthKeySet bool `json:"auth_key_set"`
|
AuthKeySet bool `json:"auth_key_set"`
|
||||||
|
// AdminKey 是**管理接口专用**凭证(v0.3.0 新增,可选)。
|
||||||
|
// 为空表示未单独配置,管理接口回退用 auth_key(向后兼容)。
|
||||||
|
AdminKey string `json:"admin_key"` // 打码形式
|
||||||
|
AdminKeySet bool `json:"admin_key_set"` // 是否单独配置了 admin_key
|
||||||
|
AdminKeySource string `json:"admin_key_source"` // admin_key / auth_key / env / ""(未配置)
|
||||||
CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*)
|
CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*)
|
||||||
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /)
|
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /)
|
||||||
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用)
|
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用)
|
||||||
@@ -58,6 +63,9 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
APIKeySet: all["api_key"] != "",
|
APIKeySet: all["api_key"] != "",
|
||||||
AuthKey: maskAPIKeyField(all["auth_key"]),
|
AuthKey: maskAPIKeyField(all["auth_key"]),
|
||||||
AuthKeySet: all["auth_key"] != "",
|
AuthKeySet: all["auth_key"] != "",
|
||||||
|
AdminKey: maskAPIKeyField(all["admin_key"]),
|
||||||
|
AdminKeySet: all["admin_key"] != "",
|
||||||
|
AdminKeySource: setting.GetAdminKeySource(),
|
||||||
CORSAllowAll: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true",
|
CORSAllowAll: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true",
|
||||||
CORSOrigins: all["cors_origins"],
|
CORSOrigins: all["cors_origins"],
|
||||||
CORSConfigured: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true" || all["cors_origins"] != "",
|
CORSConfigured: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true" || all["cors_origins"] != "",
|
||||||
@@ -298,10 +306,74 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettingsAdminKeyRequest 是 PUT /api/admin/settings/admin-key 的 body。
|
||||||
|
// admin_key 是**管理接口专用**凭证;与 auth_key(业务侧 /v1/audio/speech 鉴权)分离后,
|
||||||
|
// 业务调用方拿到的 key 不再能访问管理接口。
|
||||||
|
// 传空串表示"清除独立管理凭证",管理接口回退用 auth_key(即旧行为)。
|
||||||
|
type SettingsAdminKeyRequest struct {
|
||||||
|
AdminKey string `json:"admin_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsAdminKeyHandler PUT /api/admin/settings/admin-key
|
||||||
|
// 鉴权: RequireAdmin(注意:能用当前凭证改,改完下一个请求即用新凭证)。
|
||||||
|
func SettingsAdminKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := GetAdminStore()
|
||||||
|
if s == nil {
|
||||||
|
middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
|
||||||
|
var body SettingsAdminKeyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(body.AdminKey)
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
// 清除独立管理凭证 → 回退 auth_key。回退后若 auth_key 也为空,
|
||||||
|
// 管理接口将全部 401(RequireAdmin 不再空凭证放行),这里必须挡住。
|
||||||
|
authKey, _, _ := s.SettingsGet("auth_key")
|
||||||
|
if authKey == "" {
|
||||||
|
middleware.SendJSONError(w, http.StatusBadRequest,
|
||||||
|
"admin_key cannot be cleared while auth_key is empty (would lock out admin access)",
|
||||||
|
"invalid_request_error", "missing_field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.SettingsDelete("admin_key"); err != nil {
|
||||||
|
log.Printf("[settings] admin-key clear: %v", err)
|
||||||
|
middleware.SendJSONError(w, http.StatusInternalServerError, "clear admin_key failed", "server_error", "db_write_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setting.SetAdminKeys([]string{authKey}, "auth_key")
|
||||||
|
log.Printf("[settings] admin_key cleared; admin auth falls back to auth_key")
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "admin_key_set": false, "admin_key_source": "auth_key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SettingsSet("admin_key", key); err != nil {
|
||||||
|
log.Printf("[settings] admin-key set: %v", err)
|
||||||
|
middleware.SendJSONError(w, http.StatusInternalServerError, "write admin_key failed", "server_error", "db_write_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 立即生效:只单独刷新管理凭证列表,不重新 LoadRuntimeConfig(那会覆盖其它字段)
|
||||||
|
setting.SetAdminKeys([]string{key}, "admin_key")
|
||||||
|
log.Printf("[settings] admin_key updated, runtime active (next request uses new admin credential)")
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "admin_key_set": true, "admin_key_source": "admin_key"})
|
||||||
|
}
|
||||||
|
|
||||||
// SettingsCORSRequest 是 PUT /api/settings/cors 的 body。
|
// SettingsCORSRequest 是 PUT /api/settings/cors 的 body。
|
||||||
// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串":
|
// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串":
|
||||||
// - allow_all 指针: nil=未传(不动) *true=开 *false=关
|
// - allow_all 指针: nil=未传(不动) *true=开 *false=关
|
||||||
// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖
|
// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖
|
||||||
|
//
|
||||||
// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。
|
// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。
|
||||||
type SettingsCORSRequest struct {
|
type SettingsCORSRequest struct {
|
||||||
AllowAll *bool `json:"allow_all,omitempty"`
|
AllowAll *bool `json:"allow_all,omitempty"`
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ func main() {
|
|||||||
|
|
||||||
// 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态)
|
// 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态)
|
||||||
setting.LogStartupSummary()
|
setting.LogStartupSummary()
|
||||||
|
|
||||||
|
// 5.1) v0.3.0 前置校验:normal 模式下必须有管理凭证。
|
||||||
|
// 为什么 fail-fast 而不是警告:mware.RequireAdmin 在凭证为空时**拒绝**访问
|
||||||
|
// (不再像 v0.3.0 之前那样放行)。若此处不拦住,服务会正常起来,
|
||||||
|
// 但 /dashboard 与所有 /api/admin/* 全部 401 —— 相当于把自己锁在门外。
|
||||||
|
// 宁可启动失败并打印明确原因,也不要起来一个进不去后台的实例。
|
||||||
|
if installer.GetMode() == installer.ModeNormal && len(setting.GetAdminKeys()) == 0 {
|
||||||
|
log.Printf("[main][FATAL] normal 模式未配置任何管理凭证:admin_key / auth_key / OPENAI_TTS_API_KEY 均为空。")
|
||||||
|
log.Printf("[main][FATAL] 管理接口(含 /dashboard)将全部返回 401,服务拒绝启动。")
|
||||||
|
log.Fatalf("no admin credential configured; set admin_key (or auth_key) before starting in normal mode")
|
||||||
|
}
|
||||||
|
if installer.GetMode() == installer.ModeNormal {
|
||||||
|
log.Printf("[main] 管理凭证来源: %s", setting.GetAdminKeySource())
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
|
log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
|
||||||
|
|
||||||
controller.InitController()
|
controller.InitController()
|
||||||
|
|||||||
+17
-10
@@ -9,14 +9,19 @@ import (
|
|||||||
"github.com/volcano-tts/tts-api/setting"
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequireAdmin 是 /admin 路由的鉴权中间件,复用 OPENAI_TTS_API_KEY。
|
// RequireAdmin 是管理接口(/api/admin/*、/api/voices*、/api/settings*)的鉴权中间件。
|
||||||
// 行为:
|
|
||||||
// - Auth.APIKeys 为空 → 所有请求放行(等同无鉴权)
|
|
||||||
// - Authorization 头 Bearer token 在列表中 → 放行
|
|
||||||
// - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'}
|
|
||||||
//
|
//
|
||||||
// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys,
|
// 凭证来源:setting.GetAdminKeys(),优先级 admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。
|
||||||
// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。
|
// 与业务侧鉴权(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 {
|
func RequireAdmin(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
|
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
|
||||||
@@ -25,10 +30,12 @@ func RequireAdmin(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := setting.GetAuthAPIKeys()
|
keys := setting.GetAdminKeys()
|
||||||
if len(keys) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,11 @@ func Setup() *mux.Router {
|
|||||||
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
|
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
|
||||||
r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT")
|
r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT")
|
||||||
r.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
|
r.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
|
||||||
|
// v0.3.0: 管理凭证(admin_key)独立配置端点。
|
||||||
|
// 同时注册新旧两个路径:/api/admin/settings/admin-key 是阶段 3 分层后的正式路径,
|
||||||
|
// /api/settings/admin-key 属于旧前缀,保留为别名(与阶段 3 的别名策略一致)。
|
||||||
|
r.Handle("/api/admin/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT")
|
||||||
|
r.Handle("/api/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT")
|
||||||
r.Handle("/api/settings/cors", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsCORSHandler))).Methods("PUT")
|
r.Handle("/api/settings/cors", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsCORSHandler))).Methods("PUT")
|
||||||
|
|
||||||
// 业务路由
|
// 业务路由
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ var (
|
|||||||
ttsTimeout time.Duration = common.DefaultTimeout
|
ttsTimeout time.Duration = common.DefaultTimeout
|
||||||
ttsConfigErr error
|
ttsConfigErr error
|
||||||
authAPIKeys []string
|
authAPIKeys []string
|
||||||
|
// adminAPIKeys 是**管理接口专用**凭证(admin_key)。
|
||||||
|
// 与 authAPIKeys(业务侧,/v1/audio/speech 用)分离:
|
||||||
|
// - admin_key 为空时回退到 auth_key/env,老部署行为完全不变
|
||||||
|
// - admin_key 配了之后,业务 key 无法访问 /api/admin/*,权限隔离成立
|
||||||
|
// 详见 docs/IMPLEMENT_v0.3.0.md 阶段 1。
|
||||||
|
adminAPIKeys []string
|
||||||
|
// adminKeySource 记录管理凭证的来源,仅用于启动摘要与排障。
|
||||||
|
adminKeySource string
|
||||||
// corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取,
|
// corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取,
|
||||||
// 避免 CORSConfig 整体读时被 Lock 阻塞热路径。
|
// 避免 CORSConfig 整体读时被 Lock 阻塞热路径。
|
||||||
corsAllowAll bool
|
corsAllowAll bool
|
||||||
@@ -108,6 +116,40 @@ func SetAuthAPIKeys(keys []string) {
|
|||||||
authAPIKeys = out
|
authAPIKeys = out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAdminKeys 读**管理接口专用**凭证列表;返回拷贝防止业务侧持有底层 slice。
|
||||||
|
// 供 middleware.RequireAdmin 使用;业务侧鉴权请用 GetAuthAPIKeys。
|
||||||
|
func GetAdminKeys() []string {
|
||||||
|
ttsMu.RLock()
|
||||||
|
defer ttsMu.RUnlock()
|
||||||
|
if len(adminAPIKeys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, len(adminAPIKeys))
|
||||||
|
copy(out, adminAPIKeys)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAdminKeys 整体替换管理凭证;入参被复制。source 仅用于启动摘要展示。
|
||||||
|
func SetAdminKeys(keys []string, source string) {
|
||||||
|
ttsMu.Lock()
|
||||||
|
defer ttsMu.Unlock()
|
||||||
|
adminKeySource = source
|
||||||
|
if len(keys) == 0 {
|
||||||
|
adminAPIKeys = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]string, len(keys))
|
||||||
|
copy(out, keys)
|
||||||
|
adminAPIKeys = out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminKeySource 返回管理凭证来源:admin_key / auth_key / env / ""(未配置)。
|
||||||
|
func GetAdminKeySource() string {
|
||||||
|
ttsMu.RLock()
|
||||||
|
defer ttsMu.RUnlock()
|
||||||
|
return adminKeySource
|
||||||
|
}
|
||||||
|
|
||||||
// GetCORSAllowAll 读 CORS 是否放行所有来源。
|
// GetCORSAllowAll 读 CORS 是否放行所有来源。
|
||||||
func GetCORSAllowAll() bool {
|
func GetCORSAllowAll() bool {
|
||||||
ttsMu.RLock()
|
ttsMu.RLock()
|
||||||
@@ -391,6 +433,18 @@ func LoadRuntimeConfig(s Store) error {
|
|||||||
SetAuthAPIKeys(nil)
|
SetAuthAPIKeys(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理凭证:admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)
|
||||||
|
// 分离的目的:业务调用方拿到的 key 不应同时拥有管理后台权限。
|
||||||
|
// 不配 admin_key 时行为与旧版完全一致(回退用 auth_key),保证向后兼容。
|
||||||
|
switch {
|
||||||
|
case all["admin_key"] != "":
|
||||||
|
SetAdminKeys([]string{all["admin_key"]}, "admin_key")
|
||||||
|
case authKey != "":
|
||||||
|
SetAdminKeys([]string{authKey}, "auth_key")
|
||||||
|
default:
|
||||||
|
SetAdminKeys(nil, "")
|
||||||
|
}
|
||||||
|
|
||||||
// CORS 配置:DB > env
|
// CORS 配置:DB > env
|
||||||
// cors_allow_all (bool): 允许所有来源(*)
|
// cors_allow_all (bool): 允许所有来源(*)
|
||||||
// cors_origins (string): 逗号分隔白名单
|
// cors_origins (string): 逗号分隔白名单
|
||||||
@@ -540,6 +594,16 @@ func LogStartupSummary() {
|
|||||||
log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys))
|
log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.3.0:管理凭证独立于业务凭证(admin_key > auth_key > env)
|
||||||
|
switch GetAdminKeySource() {
|
||||||
|
case "admin_key":
|
||||||
|
log.Printf("管理凭证: 使用独立 admin_key(业务 key 无法访问管理接口)")
|
||||||
|
case "auth_key":
|
||||||
|
log.Printf("管理凭证: 未单独配置 admin_key,回退使用 auth_key(业务 key 同时拥有管理权限)")
|
||||||
|
default:
|
||||||
|
log.Printf("管理凭证: ✗ 未配置(normal 模式下服务将拒绝启动)")
|
||||||
|
}
|
||||||
|
|
||||||
allowAll := GetCORSAllowAll()
|
allowAll := GetCORSAllowAll()
|
||||||
origins := GetCORSOrigins()
|
origins := GetCORSOrigins()
|
||||||
if allowAll {
|
if allowAll {
|
||||||
|
|||||||
Reference in New Issue
Block a user