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