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

+142
View File
@@ -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)
}