onesvm-browser-server/server/internal/auth/auth.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

133 lines
4.5 KiB
Go
Raw Permalink 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:密钥哈希方案修正说明与统一入口(Issue/Check 一致性)。
//
// 存储方案(本文件为权威实现):
//
// hash 列 = SHA-256("lookup:" + globalSalt + ":" + plaintext)(确定性、可索引、常数时间比对)
// salt 列 = per-key 随机盐 hex(审计与将来迁移用,不参与校验)
//
// 全局盐必须来自 ${BROWSER_SERVER_KEYS_SEED:?required}(config.MustEnv,缺省 panic)。
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"time"
"onesvm.com/onesvm/browser-server/internal/config"
"onesvm.com/onesvm/browser-server/internal/store"
)
// KeyPrefix 明文 key 前缀(mcp-usage §1.2)。
const KeyPrefix = "bs_"
// Verifier key 签发/校验器。
type Verifier struct {
db *store.DB
salt string
}
// NewVerifier 构造校验器。seedSalt 为空即 panic(fail-closed,禁默认值兜底)。
func NewVerifier(db *store.DB, seedSalt string) *Verifier {
if seedSalt == "" {
panic("auth: 密钥盐为空(BROWSER_SERVER_KEYS_SEED 未注入,fail-closed)")
}
return &Verifier{db: db, salt: seedSalt}
}
// Salt 暴露盐值(CLI/管理面签发与 gateway 校验同源)。
func (v *Verifier) Salt() string { return v.salt }
// Error 认证/配额错误(映射 contract 错误码字面量,避免循环依赖)。
type Error struct {
Code string // unauthorized | quota | rate_limited
Message string
}
// Error 实现 error 接口。
func (e *Error) Error() string { return e.Code + ": " + e.Message }
// Issue 签发新 key:明文仅本次回传。scopes 缺省 search,read。
func (v *Verifier) Issue(consumerID int64, name string, scopes []string,
rpm, daily, monthly, sessions int, expiresAt *time.Time) (plaintext string, keyID int64, err error) {
if len(scopes) == 0 {
scopes = []string{"search", "read"}
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", 0, fmt.Errorf("auth: 随机数生成失败: %w", err)
}
plaintext = KeyPrefix + base64.RawURLEncoding.EncodeToString(raw)
perKeySalt := make([]byte, 16)
if _, err := rand.Read(perKeySalt); err != nil {
return "", 0, fmt.Errorf("auth: 盐生成失败: %w", err)
}
k := &store.ApiKey{
ConsumerID: consumerID,
Prefix: plaintext[:10],
Name: name,
Hash: LookupHash(v.salt, plaintext), // 校验同源
Salt: hex.EncodeToString(perKeySalt),
Scopes: scopes,
RPM: rpm,
DailyQuota: daily,
MonthlyQuota: monthly,
ConcurrentSessions: sessions,
Status: "enabled",
ExpiresAt: expiresAt,
}
keyID, err = v.db.CreateKey(k)
if err != nil {
return "", 0, fmt.Errorf("auth: Issue 落库: %w", err)
}
return plaintext, keyID, nil
}
// LookupHash 查询索引哈希:SHA-256("lookup:" + globalSalt + ":" + plaintext)。
// 单向:库泄露不泄露明文;比对用 hmac.Equal 常数时间。
func LookupHash(globalSalt, plaintext string) string {
h := sha256.New()
h.Write([]byte("lookup:" + globalSalt + ":"))
h.Write([]byte(plaintext))
return hex.EncodeToString(h.Sum(nil))
}
// Check 校验明文 key:粗检 → 查库 → hmac.Equal 常数时间比对 → 过期/吊销判定。
func (v *Verifier) Check(plaintext string) (*store.ApiKey, *Error) {
plaintext = strings.TrimSpace(plaintext)
if !strings.HasPrefix(plaintext, KeyPrefix) || len(plaintext) < len(KeyPrefix)+8 {
return nil, &Error{Code: "unauthorized", Message: "key 缺失或格式非法"}
}
lookup := LookupHash(v.salt, plaintext)
k, err := v.db.KeyByHash(lookup)
if err != nil {
// 假比对抹平时序差(查无此 key 与比对失败不可区分)。
hmac.Equal([]byte(lookup), []byte("0000000000000000000000000000000000000000000000000000000000000000"))
return nil, &Error{Code: "unauthorized", Message: "key 无效"}
}
if !hmac.Equal([]byte(lookup), []byte(k.Hash)) {
return nil, &Error{Code: "unauthorized", Message: "key 无效"}
}
now := config.Now()
if k.Status == "disabled" || k.Status == "revoked" {
return nil, &Error{Code: "unauthorized", Message: "key 已吊销或禁用"}
}
if k.ExpiresAt != nil && now.After(*k.ExpiresAt) {
return nil, &Error{Code: "unauthorized", Message: "key 已过期"}
}
return k, nil
}
// HasScope 判断 key 是否含某 scope。
func HasScope(k *store.ApiKey, scope string) bool {
for _, s := range k.Scopes {
if strings.TrimSpace(s) == scope {
return true
}
}
return false
}