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

+430
View File
@@ -0,0 +1,430 @@
package auth
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件锁定 K1(API key 落盘):修「每次重启 key 全失效」。核心回归是
// TestAPIKeyStore_SurvivesRestart —— 用同一个库丢掉实例再重建,key 仍然可认证。
// 既有内存用例(apikey_test.go / rbac_test.go)断言一字不改,用来证明「未接库的
// 路径行为不变」。
// errKeyStoreDown 是内存桩注入的落盘失败。
var errKeyStoreDown = errors.New("api key store down")
// openMigratedKeyDB 打开(文件或 :memory:)库并跑完全部迁移。跨重启用例要用文件,
// 因为「重启仍在」必须跨连接读同一份数据。
func openMigratedKeyDB(t *testing.T, path string) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, path, nil)
if err != nil {
t.Fatalf("db.Open(%s): %v", path, err)
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("db.MigrateUp: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// newStoredKeyManager 构建「已接上 SQLite 落盘载体」的 key 管理器(等价于组合根
// wireAPIKeyStore 的那两步:构造 store → Attach)。
func newStoredKeyManager(t *testing.T, conn *sql.DB) *APIKey {
t.Helper()
store, err := NewSQLiteKeyStore(conn, nil)
if err != nil {
t.Fatalf("NewSQLiteKeyStore: %v", err)
}
keys := NewAPIKey(config.AuthConfig{})
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
t.Fatalf("AttachKeyStore: %v", err)
}
return keys
}
// apiKeysTableExists 报告 api_keys 表是否存在。
func apiKeysTableExists(t *testing.T, ctx context.Context, conn *sql.DB) bool {
t.Helper()
var name string
err := conn.QueryRowContext(ctx,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'api_keys'`).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return false
}
if err != nil {
t.Fatalf("query sqlite_master: %v", err)
}
return true
}
// migrationStepsPastKey 计算「回滚到 id 之前」需要几步(按 schema_migrations 现算,
// 因此之后新增迁移不会把本测试带偏)。
func migrationStepsPastKey(t *testing.T, ctx context.Context, conn *sql.DB, id string) int {
t.Helper()
rows, err := conn.QueryContext(ctx, `SELECT id FROM schema_migrations`)
if err != nil {
t.Fatalf("列出已应用迁移:%v", err)
}
defer func() { _ = rows.Close() }()
steps := 0
for rows.Next() {
var got string
if err := rows.Scan(&got); err != nil {
t.Fatalf("读取迁移 id:%v", err)
}
if got >= id {
steps++
}
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历迁移:%v", err)
}
if steps == 0 {
t.Fatalf("已应用迁移里没有 %s,本测试的前提不成立", id)
}
return steps
}
// TestAPIKeyStore_SurvivesRestart 是 K1 的核心回归:签发 → 丢弃实例(关连接,
// 等价于进程退出)→ 用同一个库重新构造 → Verify 仍为 true,且 Lookup 给 owner、
// List 里能看见同一条脱敏 id(面板跨重启仍能辨认与吊销它)。
func TestAPIKeyStore_SurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "cyrene.db")
first := openMigratedKeyDB(t, path)
keys := newStoredKeyManager(t, first)
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
id := MaskKey(key)
if err := first.Close(); err != nil { // 模拟进程退出
t.Fatalf("close first connection: %v", err)
}
second := openMigratedKeyDB(t, path)
restarted := newStoredKeyManager(t, second)
if !restarted.Verify(key) {
t.Fatal("重启后 Verify(key) = false:key 没有跨重启存活(未落盘或未载入)")
}
if role, ok := restarted.Lookup(key); !ok || role != RoleOwner {
t.Fatalf("重启后 Lookup = (%q,%v), want (owner,true)", role, ok)
}
found := false
for _, s := range restarted.List() {
if s.ID == id {
found = true
}
}
if !found {
t.Fatalf("重启后 List 里没有 id %q,面板将无法吊销这把 key", id)
}
}
// TestAPIKeyStore_RevokedKeyStaysRevoked 锁定「吊销持久」:Revoke 后重建实例,
// 同一把 key 仍然无效(吊销不能只活在内存里)。
func TestAPIKeyStore_RevokedKeyStaysRevoked(t *testing.T) {
path := filepath.Join(t.TempDir(), "cyrene.db")
first := openMigratedKeyDB(t, path)
keys := newStoredKeyManager(t, first)
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
keys.Revoke(key)
if keys.Verify(key) {
t.Fatal("Revoke 后 Verify = true, want false")
}
if err := first.Close(); err != nil {
t.Fatalf("close first connection: %v", err)
}
restarted := newStoredKeyManager(t, openMigratedKeyDB(t, path))
if restarted.Verify(key) {
t.Fatal("重启后被吊销的 key 复活了(吊销未落盘)")
}
}
// TestAPIKeyStore_RevokeAllStaysRevoked 锁定「全部吊销」也跨重启生效,且库里
// 保留行(标记 revoked_at,不删行)。
func TestAPIKeyStore_RevokeAllStaysRevoked(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "cyrene.db")
first := openMigratedKeyDB(t, path)
keys := newStoredKeyManager(t, first)
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if got := keys.RevokeAll(); got != 1 {
t.Fatalf("RevokeAll() = %d, want 1", got)
}
var rows, live int
if err := first.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys`).Scan(&rows); err != nil {
t.Fatalf("count api_keys: %v", err)
}
if err := first.QueryRowContext(ctx,
`SELECT COUNT(*) FROM api_keys WHERE revoked_at IS NULL`).Scan(&live); err != nil {
t.Fatalf("count live api_keys: %v", err)
}
if rows != 1 || live != 0 {
t.Fatalf("RevokeAll 后 rows=%d live=%d, want rows=1 live=0(标记而非删行)", rows, live)
}
if err := first.Close(); err != nil {
t.Fatalf("close first connection: %v", err)
}
restarted := newStoredKeyManager(t, openMigratedKeyDB(t, path))
if restarted.Verify(key) {
t.Fatal("重启后 RevokeAll 吊销的 key 复活了")
}
}
// TestAPIKeyStore_StoresHashOnly 硬断言「库里只有哈希」:逐列取值,明文既不等于
// 任何列、也不作为子串出现在任何列里;key_hash 必须等于独立算出的 SHA-256 hex。
func TestAPIKeyStore_StoresHashOnly(t *testing.T) {
ctx := context.Background()
conn := openMigratedKeyDB(t, ":memory:")
keys := newStoredKeyManager(t, conn)
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
var id, name, keyHash, createdAt, lastUsed, revokedAt sql.NullString
if err := conn.QueryRowContext(ctx,
`SELECT id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys`).
Scan(&id, &name, &keyHash, &createdAt, &lastUsed, &revokedAt); err != nil {
t.Fatalf("select api_keys row: %v", err)
}
columns := map[string]sql.NullString{
"id": id, "name": name, "key_hash": keyHash,
"created_at": createdAt, "last_used_at": lastUsed, "revoked_at": revokedAt,
}
for column, value := range columns {
if value.Valid && (value.String == key || strings.Contains(value.String, key)) {
t.Fatalf("列 %s 里出现了明文 key", column)
}
}
sum := sha256.Sum256([]byte(key))
if want := hex.EncodeToString(sum[:]); keyHash.String != want {
t.Fatalf("key_hash = %q, want sha256 %q", keyHash.String, want)
}
}
// TestAPIKeyStore_MigrationUpDownUp 锁定迁移 0016 可逆且幂等(临时库,不碰 data/)。
func TestAPIKeyStore_MigrationUpDownUp(t *testing.T) {
ctx := context.Background()
conn := openMigratedKeyDB(t, filepath.Join(t.TempDir(), "cyrene.db"))
if !apiKeysTableExists(t, ctx, conn) {
t.Fatal("api_keys 应在 MigrateUp 后存在")
}
steps := migrationStepsPastKey(t, ctx, conn, "0016_api_keys")
if err := db.MigrateDown(ctx, conn, steps, nil); err != nil {
t.Fatalf("MigrateDown(%d): %v", steps, err)
}
if apiKeysTableExists(t, ctx, conn) {
t.Fatal("api_keys 应在 MigrateDown 后消失")
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
t.Fatalf("MigrateUp(2nd): %v", err)
}
if !apiKeysTableExists(t, ctx, conn) {
t.Fatal("api_keys 应在第二次 MigrateUp 后重新出现")
}
var rows int
if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys`).Scan(&rows); err != nil {
t.Fatalf("count api_keys: %v", err)
}
if rows != 0 {
t.Fatalf("回滚再升级后应有 0 行,得到 %d 行(回滚未清空?)", rows)
}
}
// TestAPIKey_GeneratePersistFailureKeepsMemory 锁定「先落库、后改内存」:落库失败
// 时 Generate 返回错误,且这把 key **不进入**内存表(否则本进程内它仍能过鉴权、
// 重启后又消失,等于一次假签发)。
func TestAPIKey_GeneratePersistFailureKeepsMemory(t *testing.T) {
t.Parallel()
store := newMemoryKeyStore()
keys := NewAPIKey(config.AuthConfig{})
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
t.Fatalf("AttachKeyStore: %v", err)
}
store.failWrite = true
key, err := keys.Generate()
if err == nil {
t.Fatal("落库失败时 Generate = nil error, want error")
}
if key != "" {
t.Fatalf("落库失败时仍返回了明文 %q,调用方可能把它当签发成功", key)
}
if keys.Verify(key) {
t.Fatal("落库失败的 key 进入了内存表")
}
if got := len(keys.List()); got != 0 {
t.Fatalf("落库失败后内存表有 %d 条记录, want 0", got)
}
}
// TestAPIKey_RevokePersistFailureKeepsKey 锁定吊销的「先落库、后改内存」:库标记
// 失败时内存不动(本进程内仍有效),绝不出现「本进程说已吊销、重启又复活」的反向
// 不一致。
func TestAPIKey_RevokePersistFailureKeepsKey(t *testing.T) {
t.Parallel()
store := newMemoryKeyStore()
keys := NewAPIKey(config.AuthConfig{})
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
t.Fatalf("AttachKeyStore: %v", err)
}
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
store.failWrite = true
keys.Revoke(key)
if !keys.Verify(key) {
t.Fatal("落库失败时 Revoke 仍移出了内存,重启后这把 key 会复活")
}
if keys.RevokeByMask(MaskKey(key)) {
t.Fatal("落库失败时 RevokeByMask = true, want false")
}
if got := keys.RevokeAll(); got != 0 {
t.Fatalf("落库失败时 RevokeAll = %d, want 0", got)
}
}
// TestAPIKey_AttachLoadFailureStillPersists 锁定 AttachKeyStore 在 Load 失败时仍
// 接上 store(与 AttachPolicyStore 的「Load 失败就不接线」刻意不同):否则签发会
// 静默退回内存态,正是 K1 要消灭的假成功。
func TestAPIKey_AttachLoadFailureStillPersists(t *testing.T) {
t.Parallel()
store := newMemoryKeyStore()
store.failLoad = true
keys := NewAPIKey(config.AuthConfig{})
if err := keys.AttachKeyStore(context.Background(), store); err == nil {
t.Fatal("Load 失败时 AttachKeyStore = nil error, want error")
}
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !keys.Verify(key) {
t.Fatal("Attach 后签发的 key 无法认证")
}
if len(store.live()) != 1 {
t.Fatal("Load 失败后签发的 key 没有落库(静默退回内存态)")
}
}
// TestAPIKeyStore_MemoryHitNeverReQueries 锁定「内存命中即通过、未命中不回查库」:
// 直连库把行标为已吊销后,本进程仍然照内存表放行(下个进程才失效);未命中的 key
// 也不会去查库(LoadUnrevoked 只被 Attach 调用一次)。
func TestAPIKeyStore_MemoryHitNeverReQueries(t *testing.T) {
t.Parallel()
store := newMemoryKeyStore()
keys := NewAPIKey(config.AuthConfig{})
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
t.Fatalf("AttachKeyStore: %v", err)
}
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
store.hashes[hashAPIKey(key)] = true // 绕过管理器,直接标记库中已吊销
if !keys.Verify(key) {
t.Fatal("内存命中时 Verify = false, want true(鉴权路径不应回查库)")
}
if keys.Verify("unknown-key") {
t.Fatal("未知 key 通过了校验")
}
if store.loadCalls != 1 {
t.Fatalf("LoadUnrevoked 被调用 %d 次, want 1(仅 Attach 时载入)", store.loadCalls)
}
// nil store 是合法的空操作:纯内存(既有行为)。
plain := NewAPIKey(config.AuthConfig{})
if err := plain.AttachKeyStore(context.Background(), nil); err != nil {
t.Fatalf("AttachKeyStore(nil) = %v, want nil", err)
}
plainKey, err := plain.Generate()
if err != nil || !plain.Verify(plainKey) {
t.Fatalf("纯内存路径失效:key=%q err=%v", plainKey, err)
}
}
// memoryKeyStore 是 KeyStore 的内存桩:语义与 SQLite 实现一致(hashes 记录
// hash → 是否已吊销),failLoad/failWrite 与 loadCalls 用于断言与故障注入。
type memoryKeyStore struct {
hashes map[string]bool
failLoad bool
failWrite bool
loadCalls int
}
func newMemoryKeyStore() *memoryKeyStore {
return &memoryKeyStore{hashes: make(map[string]bool)}
}
// live 返回当前未吊销的哈希,供测试断言落库结果。
func (m *memoryKeyStore) live() []string {
out := make([]string, 0, len(m.hashes))
for hash, revoked := range m.hashes {
if !revoked {
out = append(out, hash)
}
}
return out
}
func (m *memoryKeyStore) LoadUnrevoked(_ context.Context) ([]string, error) {
m.loadCalls++
if m.failLoad {
return nil, errKeyStoreDown
}
return m.live(), nil
}
func (m *memoryKeyStore) Insert(_ context.Context, keyHash string) error {
if m.failWrite {
return errKeyStoreDown
}
m.hashes[keyHash] = false
return nil
}
func (m *memoryKeyStore) Revoke(_ context.Context, keyHash string) error {
if m.failWrite {
return errKeyStoreDown
}
m.hashes[keyHash] = true
return nil
}
func (m *memoryKeyStore) RevokeAll(_ context.Context) (int, error) {
if m.failWrite {
return 0, errKeyStoreDown
}
n := 0
for hash, revoked := range m.hashes {
if !revoked {
m.hashes[hash] = true
n++
}
}
return n, nil
}