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 无法认证")
|
||||
}
|
||||
}
|
||||
+211
-56
@@ -1,8 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
@@ -11,21 +14,33 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// APIKey manages a bounded, in-memory constant table of API keys. 按裁决
|
||||
// 15.4.3(主人原话:「key 始终给超级管理员权限」),表里**不再绑定角色**:
|
||||
// 任何已登记且未吊销的 key 鉴权后一律 owner 级 —— key 用于管理员调试,甚至
|
||||
// 生产环境救火,因此不做权限分级。Keys are generated with crypto/rand and
|
||||
// stored by their string form. This suits the current single-binary milestone;
|
||||
// production should persist keys (e.g. hashed) and support revocation.
|
||||
// maskEllipsis 是脱敏标识中间的省略符;maskEdgeLen 是两端保留的十六进制位数。
|
||||
const (
|
||||
maskEllipsis = "••••"
|
||||
maskEdgeLen = 4
|
||||
)
|
||||
|
||||
// APIKey 管理一张有界、常量的 API key 表。按裁决 15.4.3(主人原话:「key 始终给
|
||||
// 超级管理员权限」),表里**不再绑定角色**:任何已登记且未吊销的 key 鉴权后一律
|
||||
// owner 级 —— key 用于管理员调试,甚至生产环境救火,因此不做权限分级。
|
||||
//
|
||||
// 表里存的是 key 的 **SHA-256 哈希**(十六进制),不是明文:明文只在 Generate 的
|
||||
// 返回值里出现一次,既不驻留内存也不落库、不落日志。这样「内存副本」与「api_keys
|
||||
// 表」的凭据口径完全一致(迁移 0016_api_keys),重启后把库里未吊销的哈希载回内存
|
||||
// 即可复原,无需也不可能还原明文。
|
||||
//
|
||||
// store 为 nil 时是本包原有的纯内存行为(测试与未接库的构建路径),行为不变。
|
||||
type APIKey struct {
|
||||
mu sync.RWMutex
|
||||
cfg config.AuthConfig
|
||||
keys map[string]struct{}
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cfg config.AuthConfig
|
||||
keys map[string]struct{}
|
||||
store KeyStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewAPIKey returns an empty key manager. cfg is accepted for constructor
|
||||
// parity and future persistence settings; keys are runtime-generated.
|
||||
// NewAPIKey 返回一个空的 key 管理器。cfg 暂只为构造签名的一致性而接收(暂无
|
||||
// 影响 key 行为的字段);落盘载体由 AttachKeyStore 单独注入,因此未接库的调用方
|
||||
// (既有测试、纯内存用法)拿到的就是原样的内存表。
|
||||
func NewAPIKey(cfg config.AuthConfig) *APIKey {
|
||||
return &APIKey{
|
||||
cfg: cfg,
|
||||
@@ -34,10 +49,47 @@ func NewAPIKey(cfg config.AuthConfig) *APIKey {
|
||||
}
|
||||
}
|
||||
|
||||
// AttachKeyStore 把可选的落盘载体接到 key 管理器(迁移 0016_api_keys;K1):
|
||||
//
|
||||
// 1. 记下 store —— 此后 Generate/Revoke/RevokeAll 先落库、成功后才改内存;
|
||||
// 2. 立即 LoadUnrevoked 把库里**未吊销**的哈希载入内存,于是重启后已签发的
|
||||
// key 仍能通过 Verify(这就是本修复要的行为)。
|
||||
//
|
||||
// store 为 nil 是合法的空操作(保持纯内存,即既有行为)。Load 失败时返回错误,
|
||||
// 但**store 依然被接上**:写路径随后会**响亮地**失败(Generate 返回错误),而不是
|
||||
// 悄悄退回「只在内存里成功」—— 那正是 K1 要消灭的静默失效(签发端点报 201、重启
|
||||
// 后全部 401)。此处与 capability/registry.AttachPolicyStore「Load 失败就不接线」
|
||||
// 刻意不同:那边默认值本身就是最严(fail-closed),这边不接线的默认是**不落盘**。
|
||||
func (k *APIKey) AttachKeyStore(ctx context.Context, store KeyStore) error {
|
||||
if k == nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
k.mu.Lock()
|
||||
k.store = store
|
||||
k.mu.Unlock()
|
||||
|
||||
hashes, err := store.LoadUnrevoked(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: load api key store: %w", err)
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
for _, hash := range hashes {
|
||||
if hash != "" {
|
||||
k.keys[hash] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate 铸造一个新 key 并登记进表,返回**仅此一次**可见的明文(绝不记录到
|
||||
// 日志)。role 是可选形参:传入时只校验它是不是已知角色名(未知即报
|
||||
// ErrInvalidRole),**不参与存储、不影响授权** —— 按裁决 15.4.3,key 表已无
|
||||
// 角色维度,任何有效 key 都是超管级(见 Lookup)。
|
||||
// 日志、绝不落库:库里只写它的哈希)。已接上 store 时**先落库后改内存**(照
|
||||
// capability/registry.SetPolicy 的既有纪律):落库失败 ⇒ 内存不变 + 返回错误,
|
||||
// 绝不出现「面板说签发成功、重启却不认」的假成功。
|
||||
//
|
||||
// role 是可选形参:传入时只校验它是不是已知角色名(未知即报 ErrInvalidRole),
|
||||
// **不参与存储、不影响授权** —— 按裁决 15.4.3,key 表已无角色维度,任何有效 key
|
||||
// 都是超管级(见 Lookup)。
|
||||
func (k *APIKey) Generate(role ...Role) (string, error) {
|
||||
if len(role) > 0 && !role[0].Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role[0])
|
||||
@@ -47,39 +99,58 @@ func (k *APIKey) Generate(role ...Role) (string, error) {
|
||||
return "", fmt.Errorf("auth: generate api key: %w", err)
|
||||
}
|
||||
key := base64.RawURLEncoding.EncodeToString(raw)
|
||||
hash := hashAPIKey(key)
|
||||
|
||||
if err := k.persistInsert(hash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
k.mu.Lock()
|
||||
k.keys[key] = struct{}{}
|
||||
k.keys[hash] = struct{}{}
|
||||
k.mu.Unlock()
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Verify reports whether key is a known, unrevoked API key.
|
||||
// Verify 报告 key 是否是一把已登记且未吊销的 API key。
|
||||
func (k *APIKey) Verify(key string) bool {
|
||||
_, ok := k.Lookup(key)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Lookup 报告 key 是否已登记,并返回其授权角色。按裁决 15.4.3,**任何**已登记
|
||||
// 的 key 都是超管(owner)级。key 不在表中时 ok 为 false,调用方必须据此拒绝。
|
||||
// Lookup 报告 key 是否已登记,并返回其授权角色。按裁决 15.4.3,**任何**已登记的
|
||||
// key 都是超管(owner)级。key 不在表中时 ok 为 false,调用方必须据此拒绝。
|
||||
//
|
||||
// 判定就是一次按哈希的 map 查表:未命中**不回查库**(启动时已载入全部有效哈希),
|
||||
// 因此鉴权路径既不碰磁盘、也不做明文逐字节比较,天然 fail-closed(未知/已吊销一律
|
||||
// 不在表里)。被吊销的 key 因此绝不会回落到 Bearer(见 WithAPIKey)。
|
||||
func (k *APIKey) Lookup(key string) (Role, bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
_, ok := k.keys[key]
|
||||
_, ok := k.keys[hashAPIKey(key)]
|
||||
return RoleOwner, ok
|
||||
}
|
||||
|
||||
// Revoke removes key from the constant table.
|
||||
// Revoke 吊销一把 key:**先落库标记 revoked_at,成功后才移出内存**。落库失败时
|
||||
// 内存保持不变(这把 key 在本进程内仍然有效)并记一条告警日志 —— 绝不出现「本进程
|
||||
// 说已吊销、重启后又活回来」的反向不一致。既有签名(无 ctx、无 error,网关的 404
|
||||
// 与计数出口依赖它)保持不变,因此这里用 context.Background() 并把失败降级为日志。
|
||||
func (k *APIKey) Revoke(key string) {
|
||||
hash := hashAPIKey(key)
|
||||
if err := k.persistRevoke(hash); err != nil {
|
||||
return
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
delete(k.keys, key)
|
||||
delete(k.keys, hash)
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// RevokeAll removes every currently registered API key and returns how many were
|
||||
// removed. It is the administrative "revoke all" operation: after a call the
|
||||
// table is empty, so no previously issued key can authenticate.
|
||||
// RevokeAll 移除当前已登记的每一个 API key 并返回移除数量。它是管理端的「全部
|
||||
// 吊销」:调用之后表为空,此前签发的 key 一律无法认证。已接上 store 时先把库中
|
||||
// 所有未吊销行标记为已吊销(跨重启同样失效),成功后才清空内存;落库失败则内存
|
||||
// 不动、返回 0(「一个都没废掉」是诚实的回答,而不是清空内存后重启又复活)。
|
||||
func (k *APIKey) RevokeAll() int {
|
||||
if err := k.persistRevokeAll(); err != nil {
|
||||
return 0
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
n := len(k.keys)
|
||||
@@ -87,52 +158,136 @@ func (k *APIKey) RevokeAll() int {
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeByMask removes every key whose masked identifier (the same derived id
|
||||
// List returns) matches mask. It returns true when at least one key was revoked.
|
||||
// The mask is lossy, so it is matched by recomputing it for each key; the table
|
||||
// is small so a linear scan is fine. An empty or nil-returning call is a no-op.
|
||||
// RevokeByMask 移除脱敏标识(与 List 返回的 id 同一口径)等于 mask 的每一个 key,
|
||||
// 有至少一把被移除时返回 true。掩码是有损的,因此按「为每条记录重算掩码」的方式
|
||||
// 匹配;表很小,线性扫描足够。空掩码是空操作。
|
||||
//
|
||||
// 顺序仍是「先落库、后改内存」:只有落库成功的那些才移出内存,失败的留在表里并
|
||||
// 记告警(否则重启后它们会带着未标记的行复活)。SQL 在管理器锁之外执行,避免在
|
||||
// 持锁时做 IO。
|
||||
func (k *APIKey) RevokeByMask(mask string) bool {
|
||||
if mask == "" {
|
||||
return false
|
||||
}
|
||||
matches := k.hashesMatchingMask(mask)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
persisted := make([]string, 0, len(matches))
|
||||
for _, hash := range matches {
|
||||
if err := k.persistRevoke(hash); err != nil {
|
||||
continue
|
||||
}
|
||||
persisted = append(persisted, hash)
|
||||
}
|
||||
if len(persisted) == 0 {
|
||||
return false
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
revoked := false
|
||||
for key := range k.keys {
|
||||
if maskKey(key) == mask {
|
||||
delete(k.keys, key)
|
||||
revoked = true
|
||||
}
|
||||
for _, hash := range persisted {
|
||||
delete(k.keys, hash)
|
||||
}
|
||||
return revoked
|
||||
return true
|
||||
}
|
||||
|
||||
// List returns a summary of every currently registered API key for the admin
|
||||
// panel. Key material is never returned; each identifier is masked so the panel
|
||||
// can tell keys apart without exposing the secret. The in-memory store keeps no
|
||||
// creation time, so CreatedAt is nil for every entry. 每条的角色恒为 owner
|
||||
// (裁决 15.4.3:key 不做角色分级)。
|
||||
// List 返回面板可见的每一条 key 摘要。绝不返回密钥材料:每条只给脱敏标识(哈希
|
||||
// 的首尾各 4 位十六进制),面板据此辨认与吊销。内存表不记创建时间,因此每条
|
||||
// CreatedAt 均为 nil(库里有 created_at 列,但目前不回读、不在本结构上暴露)。
|
||||
// 每条的角色恒为 owner(裁决 15.4.3:key 不做角色分级)。
|
||||
func (k *APIKey) List() []UserSummary {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
out := make([]UserSummary, 0, len(k.keys))
|
||||
for key := range k.keys {
|
||||
out = append(out, UserSummary{ID: maskKey(key), Username: "apikey", Role: RoleOwner})
|
||||
for hash := range k.keys {
|
||||
out = append(out, UserSummary{ID: maskHash(hash), Username: "apikey", Role: RoleOwner})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MaskKey renders a key's leading and trailing groups so the panel can identify
|
||||
// an entry without revealing the secret. Short keys are fully hidden. It is the
|
||||
// exported form of maskKey so callers (e.g. the admin issue handler) can return
|
||||
// the same masked id the List page shows.
|
||||
func MaskKey(key string) string { return maskKey(key) }
|
||||
|
||||
// maskKey renders a key's leading and trailing groups so the panel can identify
|
||||
// an entry without revealing the secret. Short keys are fully hidden.
|
||||
func maskKey(key string) string {
|
||||
if len(key) <= 8 {
|
||||
return "••••"
|
||||
// hashesMatchingMask 返回当前表里脱敏标识等于 mask 的全部哈希(读锁内完成)。
|
||||
func (k *APIKey) hashesMatchingMask(mask string) []string {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
out := make([]string, 0, 1)
|
||||
for hash := range k.keys {
|
||||
if maskHash(hash) == mask {
|
||||
out = append(out, hash)
|
||||
}
|
||||
}
|
||||
return key[:4] + "••••" + key[len(key)-4:]
|
||||
return out
|
||||
}
|
||||
|
||||
// persistInsert 把新 key 的哈希写进已接上的 store(未接上是空操作)。它刻意在
|
||||
// 管理器锁之外执行 SQL,调用方只在它成功后才改内存。
|
||||
func (k *APIKey) persistInsert(hash string) error {
|
||||
store := k.storeSnapshot()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if err := store.Insert(context.Background(), hash); err != nil {
|
||||
return fmt.Errorf("auth: persist api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistRevoke 在库里把哈希对应的行标记为已吊销(未接 store 是空操作)。失败时
|
||||
// 记一条告警并返回错误,由调用方决定不动内存。
|
||||
func (k *APIKey) persistRevoke(hash string) error {
|
||||
store := k.storeSnapshot()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if err := store.Revoke(context.Background(), hash); err != nil {
|
||||
k.log.Warn("api key revoke not persisted, key stays valid until restart", "err", err)
|
||||
return fmt.Errorf("auth: revoke api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistRevokeAll 在库里标记全部未吊销行为已吊销(未接 store 是空操作)。
|
||||
func (k *APIKey) persistRevokeAll() error {
|
||||
store := k.storeSnapshot()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := store.RevokeAll(context.Background()); err != nil {
|
||||
k.log.Warn("api key revoke-all not persisted, keys stay valid until restart", "err", err)
|
||||
return fmt.Errorf("auth: revoke all api keys: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeSnapshot 在锁内读一次 store 指针,避免与 AttachKeyStore 的写入竞态。
|
||||
func (k *APIKey) storeSnapshot() KeyStore {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
return k.store
|
||||
}
|
||||
|
||||
// MaskKey 返回一把 key 的脱敏标识:明文 → SHA-256 → 首尾各 4 位十六进制。它是
|
||||
// maskHash 的导出形态,供调用方(如管理端签发 handler 返回的 id、审计链的 Sub)
|
||||
// 算出与 List 页面一致的标识。
|
||||
//
|
||||
// 与修复前的旧口径(直接取明文首尾 4 位)相比,这个 id **不再泄露明文的任何一位**,
|
||||
// 而且只由哈希决定 —— 同一把 key 在任何一次重启后都得到同一个 id,面板上记下的 id
|
||||
// 跨重启仍然可吊销。
|
||||
func MaskKey(key string) string { return maskHash(hashAPIKey(key)) }
|
||||
|
||||
// maskHash 把 64 位十六进制哈希渲染成脱敏标识(首尾各 maskEdgeLen 位)。它是
|
||||
// MaskKey / List / RevokeByMask 三者共用的 id 口径,改动它等于改动面板与审计链
|
||||
// 看到的标识,因此集中在这里。
|
||||
func maskHash(hash string) string {
|
||||
if len(hash) <= maskEdgeLen*2 {
|
||||
return maskEllipsis
|
||||
}
|
||||
return hash[:maskEdgeLen] + maskEllipsis + hash[len(hash)-maskEdgeLen:]
|
||||
}
|
||||
|
||||
// hashAPIKey 返回 key 的十六进制 SHA-256 —— 内存表与 api_keys 表都只认这一形式。
|
||||
// key 是 32 字节随机秘密(256 位熵),没有低熵输入可供爆破,因此无需加盐;查表由
|
||||
// map 与唯一索引完成,不做明文逐字节比较。
|
||||
func hashAPIKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// KeyStore 持久化 API key 的哈希(迁移 0016_api_keys;K1)。
|
||||
//
|
||||
// 它**只接受哈希**:本接口没有任何接受明文的入口,因此「明文落库」在这条路径上
|
||||
// 连写错都写不出来(明文只在 APIKey.Generate 的返回值里出现一次)。
|
||||
// *SQLiteKeyStore 是生产实现,测试可换成内存桩。实现必须对并发使用安全。
|
||||
type KeyStore interface {
|
||||
// LoadUnrevoked 返回全部未吊销 key 的 hash。没有任何记录时返回空切片
|
||||
// (不是错误):那就是「还没签过 key」的诚实状态。
|
||||
LoadUnrevoked(ctx context.Context) ([]string, error)
|
||||
// Insert 记录一把新 key 的 hash。hash 为空是非法调用,返回错误。
|
||||
Insert(ctx context.Context, keyHash string) error
|
||||
// Revoke 把 hash 对应的行标记为已吊销(写 revoked_at)。未知或已吊销的
|
||||
// hash 是幂等的空操作,不返回错误。
|
||||
Revoke(ctx context.Context, keyHash string) error
|
||||
// RevokeAll 把全部未吊销的行标记为已吊销,返回受影响的行数。
|
||||
RevokeAll(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// keyStoreTimeLayout 是落盘时间戳的格式(与迁移 0016 的 strftime 默认值同形:
|
||||
// UTC RFC3339Nano)。
|
||||
const keyStoreTimeLayout = time.RFC3339Nano
|
||||
|
||||
// API key 持久化的 SQL(全参数化,项目规范 §十)。表由迁移 0016_api_keys 建立。
|
||||
const (
|
||||
selectUnrevokedAPIKeysSQL = `SELECT key_hash FROM api_keys WHERE revoked_at IS NULL`
|
||||
insertAPIKeySQL = `INSERT INTO api_keys (key_hash) VALUES (?)`
|
||||
revokeAPIKeySQL = `UPDATE api_keys SET revoked_at = ?
|
||||
WHERE key_hash = ? AND revoked_at IS NULL`
|
||||
revokeAllAPIKeysSQL = `UPDATE api_keys SET revoked_at = ? WHERE revoked_at IS NULL`
|
||||
)
|
||||
|
||||
// SQLiteKeyStore 把 API key 的哈希落盘在 SQLite 的 api_keys 表上。它只写 hash:
|
||||
// 明文既不经过本类型、也不进任何 SQL 参数。凭底层的 *sql.DB 对并发使用安全。
|
||||
type SQLiteKeyStore struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewSQLiteKeyStore 在 db 上构建 SQLite key 存储。db 为 nil 时返回 ErrNilDB。
|
||||
//
|
||||
// 它刻意不自建 DDL:迁移 0016_api_keys 是这份 schema 的唯一权威路径(与
|
||||
// capability/registry.SQLitePolicyStore 同一口径),因此表缺失时读写会返回明确的
|
||||
// 错误(调用方据此让 Generate 响亮地失败),而不是悄悄降级成「只在内存里成功、
|
||||
// 重启全部失效」的假成功 —— 那正是本修复要消灭的缺陷。log 可为 nil。
|
||||
func NewSQLiteKeyStore(db *sql.DB, log *slog.Logger) (*SQLiteKeyStore, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("auth: api key store: %w", ErrNilDB)
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &SQLiteKeyStore{db: db, log: logger.WithSource(log, "auth/apikey")}, nil
|
||||
}
|
||||
|
||||
// LoadUnrevoked 返回 api_keys 里全部未吊销行的 key_hash。空表返回空切片。
|
||||
// 日志只记条数(哈希与明文都不打)。
|
||||
func (s *SQLiteKeyStore) LoadUnrevoked(ctx context.Context) ([]string, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("auth: api key store: not configured")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, selectUnrevokedAPIKeysSQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: load api key hashes: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var hash string
|
||||
if err := rows.Scan(&hash); err != nil {
|
||||
return nil, fmt.Errorf("auth: scan api key hash: %w", err)
|
||||
}
|
||||
out = append(out, hash)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("auth: iterate api key hashes: %w", err)
|
||||
}
|
||||
s.log.Debug("api keys loaded from store", "count", len(out))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Insert 落盘一把新 key 的哈希。只有 hash 进 SQL,name/last_used_at 留 NULL
|
||||
// (当前签发入口没有命名参数,最后一用时间不做同步写库)。
|
||||
func (s *SQLiteKeyStore) Insert(ctx context.Context, keyHash string) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("auth: api key store: not configured")
|
||||
}
|
||||
if strings.TrimSpace(keyHash) == "" {
|
||||
return fmt.Errorf("auth: insert api key: empty key hash")
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, insertAPIKeySQL, keyHash); err != nil {
|
||||
return fmt.Errorf("auth: insert api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revoke 把 hash 对应的未吊销行标记为已吊销。找不到行(未知或已吊销)不报错:
|
||||
// 吊销是幂等操作,重复吊销同一把 key 应当成功。
|
||||
func (s *SQLiteKeyStore) Revoke(ctx context.Context, keyHash string) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("auth: api key store: not configured")
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, revokeAPIKeySQL, formatKeyStoreTime(time.Now()), keyHash); err != nil {
|
||||
return fmt.Errorf("auth: revoke api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeAll 把全部未吊销行标记为已吊销并返回受影响行数。删除(而非标记)会丢掉
|
||||
// 「什么时候废掉的」这一审计信息,因此这里坚持标记。
|
||||
func (s *SQLiteKeyStore) RevokeAll(ctx context.Context) (int, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return 0, fmt.Errorf("auth: api key store: not configured")
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, revokeAllAPIKeysSQL, formatKeyStoreTime(time.Now()))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: revoke all api keys: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: revoke all api keys: %w", err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// formatKeyStoreTime 把 t 渲染成 UTC RFC3339Nano 以备落盘。
|
||||
func formatKeyStoreTime(t time.Time) string {
|
||||
return t.UTC().Format(keyStoreTimeLayout)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 0016_api_keys down: 回滚落盘的 key 表。
|
||||
--
|
||||
-- 回滚后的状态是「key 只活在内存、重启全失效」的旧形态(K1 修复前):已签发的
|
||||
-- key 随表一起丢弃,重启后一律 401,需要主人重新签发。丢弃是安全的 —— 运行时
|
||||
-- 内存副本与鉴权路径不读本表以外的任何东西,也不存在引用它的外键。
|
||||
DROP INDEX IF EXISTS idx_api_keys_key_hash;
|
||||
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 0016_api_keys: API key 哈希落盘(K1 缺陷修复)。
|
||||
--
|
||||
-- 为什么必须落盘:此前 key 表只活在 internal/auth 的内存 map 里,管理面板
|
||||
-- POST /api/v1/admin/users/apikey 签出去的 key 一遇进程重启全部失效(X-API-Key
|
||||
-- 一律 401)。而 design/11 §2 把 key 定位成「管理员调试、甚至生产环境救火」的
|
||||
-- 长凭据 —— 签了就必须真能用,不能重启即作废。本表是那份凭据的权威载体:
|
||||
-- 启动时 auth.SQLiteKeyStore 载入全部 revoked_at IS NULL 的行到内存表,之后
|
||||
-- Generate/Revoke 先落库、成功后才改内存。
|
||||
--
|
||||
-- 口径:
|
||||
-- * key_hash 是明文 key 的 **SHA-256 十六进制**,库里**只存哈希**;明文只在
|
||||
-- 签发响应里出现一次,绝不落库、绝不落日志(与 0008 refresh_tokens 的
|
||||
-- token_hash 同一纪律,且本包没有任何接受明文的写库入口)。
|
||||
-- * revoked_at 为 NULL 表示仍有效;吊销只打时间戳、不删行,留审计痕迹。
|
||||
-- * name 是可空备注;created_at / last_used_at 是运营时间戳。last_used_at
|
||||
-- 当前**没有写入方**(最后一用时间不能靠「每次鉴权同步写库」拿,那样会把
|
||||
-- 鉴权路径变成写路径),有意留 NULL,等异步/批量方案落地再填。
|
||||
-- * 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
key_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- 唯一索引同时承担两件事:同一把 key 只能有一行;按 hash 查表/吊销有索引可走。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_key_hash
|
||||
ON api_keys (key_hash);
|
||||
Reference in new issue
Block a user