- 缺陷: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 已被此前重启消灭,需重签一次后即长期有效。
294 lines
11 KiB
Go
294 lines
11 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
// 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{}
|
||
store KeyStore
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewAPIKey 返回一个空的 key 管理器。cfg 暂只为构造签名的一致性而接收(暂无
|
||
// 影响 key 行为的字段);落盘载体由 AttachKeyStore 单独注入,因此未接库的调用方
|
||
// (既有测试、纯内存用法)拿到的就是原样的内存表。
|
||
func NewAPIKey(cfg config.AuthConfig) *APIKey {
|
||
return &APIKey{
|
||
cfg: cfg,
|
||
keys: make(map[string]struct{}),
|
||
log: logger.WithSource(slog.Default(), source),
|
||
}
|
||
}
|
||
|
||
// 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 并登记进表,返回**仅此一次**可见的明文(绝不记录到
|
||
// 日志、绝不落库:库里只写它的哈希)。已接上 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])
|
||
}
|
||
raw := make([]byte, keyLength)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
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[hash] = struct{}{}
|
||
k.mu.Unlock()
|
||
return key, nil
|
||
}
|
||
|
||
// 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,调用方必须据此拒绝。
|
||
//
|
||
// 判定就是一次按哈希的 map 查表:未命中**不回查库**(启动时已载入全部有效哈希),
|
||
// 因此鉴权路径既不碰磁盘、也不做明文逐字节比较,天然 fail-closed(未知/已吊销一律
|
||
// 不在表里)。被吊销的 key 因此绝不会回落到 Bearer(见 WithAPIKey)。
|
||
func (k *APIKey) Lookup(key string) (Role, bool) {
|
||
k.mu.RLock()
|
||
defer k.mu.RUnlock()
|
||
_, ok := k.keys[hashAPIKey(key)]
|
||
return RoleOwner, ok
|
||
}
|
||
|
||
// 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()
|
||
delete(k.keys, hash)
|
||
k.mu.Unlock()
|
||
}
|
||
|
||
// 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)
|
||
k.keys = make(map[string]struct{})
|
||
return n
|
||
}
|
||
|
||
// 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()
|
||
for _, hash := range persisted {
|
||
delete(k.keys, hash)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 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 hash := range k.keys {
|
||
out = append(out, UserSummary{ID: maskHash(hash), Username: "apikey", Role: RoleOwner})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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 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[:])
|
||
}
|