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:
+91
-19
@@ -16,22 +16,27 @@ import (
|
||||
// SettingsResponse 是 GET /api/settings 的响应。
|
||||
// API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。
|
||||
type SettingsResponse struct {
|
||||
APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1
|
||||
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
|
||||
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
|
||||
AuthKeySet bool `json:"auth_key_set"`
|
||||
CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*)
|
||||
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /)
|
||||
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用)
|
||||
APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1
|
||||
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
|
||||
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
|
||||
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"` // 允许所有来源(*)
|
||||
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /)
|
||||
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用)
|
||||
DefaultResourceID string `json:"default_resource_id"`
|
||||
DefaultSpeaker string `json:"default_speaker"`
|
||||
DefaultFormat string `json:"default_format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
Model string `json:"model"`
|
||||
ModelType int `json:"model_type"`
|
||||
ExplicitLanguage string `json:"explicit_language"`
|
||||
EnableSubtitle bool `json:"enable_subtitle"`
|
||||
UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用)
|
||||
DefaultSpeaker string `json:"default_speaker"`
|
||||
DefaultFormat string `json:"default_format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
Model string `json:"model"`
|
||||
ModelType int `json:"model_type"`
|
||||
ExplicitLanguage string `json:"explicit_language"`
|
||||
EnableSubtitle bool `json:"enable_subtitle"`
|
||||
UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用)
|
||||
}
|
||||
|
||||
// SettingsGetHandler GET /api/settings
|
||||
@@ -58,6 +63,9 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
APIKeySet: all["api_key"] != "",
|
||||
AuthKey: maskAPIKeyField(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",
|
||||
CORSOrigins: 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})
|
||||
}
|
||||
|
||||
// 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。
|
||||
// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串":
|
||||
// - allow_all 指针: nil=未传(不动) *true=开 *false=关
|
||||
// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖
|
||||
//
|
||||
// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。
|
||||
type SettingsCORSRequest struct {
|
||||
AllowAll *bool `json:"allow_all,omitempty"`
|
||||
@@ -386,10 +458,10 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.GetCORSAllowAll(), originsStr)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"allow_all": setting.GetCORSAllowAll(),
|
||||
"origins": originsStr,
|
||||
"cors_active": true,
|
||||
"ok": true,
|
||||
"allow_all": setting.GetCORSAllowAll(),
|
||||
"origins": originsStr,
|
||||
"cors_active": true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user