- 缺陷: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 已被此前重启消灭,需重签一次后即长期有效。
431 lines
14 KiB
Go
431 lines
14 KiB
Go
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
|
||
}
|