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:
+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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user