fix(auth): API Key 落盘(修「每次重启 key 全失效」)
- 缺陷: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:
1 parent
c3339a5d07
commit
79b1ec36aa
7 files changed
+917
-57
No files matched your search
@@ -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;面板首页的存储卡)。
|
||||
//
|
||||
|
||||
@@ -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 无法认证")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user