Files
Cyrene_v2/internal/auth/apikey_store_test.go
T
AskaEth 79b1ec36aa
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 已被此前重启消灭,需重签一次后即长期有效。
2026-10-04 19:55:18 +08:00

431 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}