fix(auth): API Key 落盘(修「每次重启 key 全失效」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺陷:internal/auth/apikey.go 的 key 表是纯内存 map(注释自认 production should persist)⇒ 一次 restart 后所有 key 一律 401;实测挡住了大附件真机 e2e,也与「key 用于生产救火」的长凭据语义矛盾。

- 迁移 0016_api_keys:表 api_keys(id/name/key_hash/created_at/last_used_at/revoked_at)+ 唯一索引 idx_api_keys_key_hash;**只存 SHA-256 hash,无明文入口**。

- 纪律:Generate 先落库后改内存(失败 ⇒ 内存不变 + 返回错误);AttachKeyStore 启动时把库中未吊销 hash 载入内存(Load 失败仍接上 store ⇒ Generate 会响亮报错,而不是静默退回「不落盘」);Revoke/RevokeAll/RevokeByMask 先落库再移出内存(落库失败 ⇒ WARN + 内存不变,fail-closed:绝不让被吊销的 key 重启后复活)。

- 内存表也改为存 hash(不再存明文)⇒ 面板 id/掩码由 hash 派生:不再泄露明文首尾、且跨重启稳定(重启前记下的 id 重启后仍可吊销)。

- 契约零变化:HTTP 请求/响应形状、网关代码、既有内存用例断言全部未动(网关构造注入点只在组合根 wireAPIKeys)。

- 主会话独立验收:隔离 worktree(K1 的 7 文件)build/vet 绿、全树 59 包 0 FAIL、key 用例 24 PASS、双平台交叉编译绿;变异独立复现(跳过落库 ⇒ 重启存活用例红)。

- 取舍与遗留:吊销落库失败在 HTTP 层仍表现为 404/revoked:0(签名零改动的代价,日志可辨);last_used_at 列已建但恒 NULL(避免把读路径变写路径);旧内存 key 已被此前重启消灭,需重签一次后即长期有效。
This commit is contained in:
AskaEth committed 2026-10-04 19:55:18 +08:00
1 parent c3339a5d07
commit 79b1ec36aa
7 files changed
+917 -57

No files matched your search

+25 -1
View File
@@ -24,7 +24,7 @@ type capacityEmitter interface {
// 返回真实数据。
func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth)
apiKeys := auth.NewAPIKey(a.cfg.Auth)
apiKeys := a.wireAPIKeys()
ragStore := knowledge.New(knowledge.Deps{
Projects: a.projStore,
Retriever: a.retriever,
@@ -116,6 +116,30 @@ func (a *app) wireFrontend() error {
return nil
}
// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key
// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点
// 长凭据的语义与「重启即作废」自相矛盾)。
//
// 降级口径与上面的 refresh store 一致:构造或载入失败**不阻断启动**,key 仍可签发
// 与校验,只是不跨重启存活;后果写进日志,不静默。注意 AttachKeyStore 在 Load 失败
// 时仍会把 store 接上(见其注释)——那样之后的签发会响亮地失败,而不是又一次
// 「面板报成功、重启全 401」。
func (a *app) wireAPIKeys() *auth.APIKey {
keys := auth.NewAPIKey(a.cfg.Auth)
if a.db == nil {
return keys
}
store, err := auth.NewSQLiteKeyStore(a.db, a.log)
if err != nil {
a.log.Warn("api key store unavailable, issued keys will not survive restart", "error", err)
return keys
}
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
a.log.Warn("api key store load failed, keys issued earlier may be rejected", "error", err)
}
return keys
}
// vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧
// 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。
//
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"database/sql"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件锁定组合根接线(K1):wireFrontend 调用的 wireAPIKeys 必须真的把 SQLite
// 载体接到 key 管理器,否则签发的 key 又回到「重启即失效」。
//
// 为什么在组合根测这件事:newApp 无法在单元测试里实例化(要开库、迁移、拉起全部
// 子系统,见 wire_context_test.go 的说明),所以这里直接对 wireAPIKeys 这一步
// 接线取证 —— 它与 newApp 里调用的是同一个函数。
// openMigratedTestDB 打开一个临时文件库并跑完全部迁移。用文件而非 :memory:,
// 因为「重启仍在」必须跨连接读同一份数据。
func openMigratedTestDB(t *testing.T, path string) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, path, testLog())
if err != nil {
t.Fatalf("db.Open(%s): %v", path, err)
}
if err := db.MigrateUp(ctx, conn, testLog()); err != nil {
_ = conn.Close()
t.Fatalf("db.MigrateUp: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// TestWireAPIKeys_KeySurvivesRestart 断言组合根接的线真的落盘:签发 → 关连接
// (进程退出)→ 用同一个库重新接线 → 同一把 key 仍能认证。
func TestWireAPIKeys_KeySurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "cyrene.db")
first := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()}
keys := first.wireAPIKeys()
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if err := first.db.Close(); err != nil {
t.Fatalf("close first connection: %v", err)
}
second := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()}
restarted := second.wireAPIKeys()
if !restarted.Verify(key) {
t.Fatal("重启后 key 无法认证:组合根没有把落盘载体接上")
}
}
// TestWireAPIKeys_NilDBFallsBackToMemory 断言没有数据库句柄时(纯内存测试/
// 构建路径)接线是空操作且不 panic:key 照旧可签发可校验,只是不跨重启。
func TestWireAPIKeys_NilDBFallsBackToMemory(t *testing.T) {
a := &app{db: nil, cfg: &config.Config{}, log: testLog()}
keys := a.wireAPIKeys() // 必须不 panic
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !keys.Verify(key) {
t.Fatal("未接库路径的 key 无法认证")
}
}