feat(auth)!: v0.3.0 阶段一 鉴权收紧 + 健康/指标端点收口
本提交由初版方案的「阶段 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:
@@ -32,6 +32,14 @@ var (
|
||||
ttsTimeout time.Duration = common.DefaultTimeout
|
||||
ttsConfigErr error
|
||||
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 下读取,
|
||||
// 避免 CORSConfig 整体读时被 Lock 阻塞热路径。
|
||||
corsAllowAll bool
|
||||
@@ -108,6 +116,40 @@ func SetAuthAPIKeys(keys []string) {
|
||||
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 是否放行所有来源。
|
||||
func GetCORSAllowAll() bool {
|
||||
ttsMu.RLock()
|
||||
@@ -391,6 +433,18 @@ func LoadRuntimeConfig(s Store) error {
|
||||
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_allow_all (bool): 允许所有来源(*)
|
||||
// cors_origins (string): 逗号分隔白名单
|
||||
@@ -540,6 +594,16 @@ func LogStartupSummary() {
|
||||
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()
|
||||
origins := GetCORSOrigins()
|
||||
if allowAll {
|
||||
|
||||
Reference in New Issue
Block a user