Files
Cyrene_v2/internal/auth/apikey_store.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

143 lines
5.7 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"
"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)
}