HotPlex v1.0 WebSocket 认证授权设计,基于行业最佳实践。
| 来源 | 推荐方案 |
|---|---|
| RFC 7519 (JWT) | 使用 alg: ES256,禁止 HS256 |
| RFC 8725 (JOSE) | 始终验证 aud (Audience) claim |
| OAuth 2.0 | 短期 Access Token + Refresh Token |
| Discord Gateway | Session Resume 机制 |
- ✅ ES256 签名:ECDSA P-256,性能优于 RSA,公钥可分发
- ✅ jti 防重放:每 Token 唯一 ID,支持 Redis 黑名单
- ✅ aud 验证:必须验证 JWT 接收方是 HotPlex Gateway
- ✅ 分层 TTL:短 Access Token + 长 Session Token
推荐:ES256 (ECDSA P-256 SHA-256)
// 签名
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, _ := token.SignedString(privateKey)
// 验证
publicKey := &privateKey.PublicKey
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return publicKey, nil
})优势对比:
| 算法 | 签名大小 | 验证性能 | 公钥分发 | 推荐度 |
|---|---|---|---|---|
| ES256 | 64B | 快 | ✅ | ⭐⭐⭐⭐⭐ |
| RS256 | 256B | 中 | ✅ | ⭐⭐⭐⭐ |
| HS256 | 32B | 快 | ❌ | ⭐⭐(仅内部服务) |
{
"iss": "hotplex-auth-service",
"sub": "user_abc123",
"aud": "hotplex-gateway",
"exp": 1710000000,
"iat": 1709999700,
"jti": "550e8400-e29b-41d4-a716-446655440000",
"role": "user",
"scope": "session:create session:read session:delete",
"bot_id": "B0123456789",
"session_id": "sess_a1b2c3d4"
}| Claim | 类型 | 必需 | 说明 |
|---|---|---|---|
iss |
string | ✅ | 签发者:hotplex-auth-service |
sub |
string | ✅ | 用户主体 ID |
aud |
string/[]string | ✅ | 必须验证:hotplex-gateway |
exp |
int64 | ✅ | 过期时间(Unix timestamp) |
iat |
int64 | ✅ | 签发时间 |
jti |
string | ✅ | JWT ID:防重放攻击 |
role |
string | ✅ | RBAC 角色 |
scope |
string | OAuth2 风格权限范围 | |
bot_id |
string | Bot 隔离标识 | |
session_id |
string | Session 绑定(可选) |
| Token 类型 | TTL | 用途 | 存储 |
|---|---|---|---|
| Access Token | 5 min | API 认证 | 内存/Redis |
| Gateway Token | 1 hour | WebSocket 连接保活 | Client 内存 |
| Refresh Token | 7 days | 刷新 Access Token | HttpOnly Cookie |
┌─────────────────────────────────────────────────────────────┐
│ WebSocket Authentication │
│ │
│ 1. 握手阶段 (Handshake) │
│ Client ──── Cookie (可选) ────► Gateway │
│ │ │
│ ▼ │
│ Cookie 无效 ──► 401 Unauthorized │
│ │ │
│ ▼ │
│ Cookie 有效 ──► 允许 Upgrade (101 Switching Protocols) │
│ │
│ 2. 首条消息认证 (First Message) │
│ Client ──── JWT (Authorization header) ──► Gateway │
│ │ │
│ ▼ │
│ JWT 验证失败 ──► error + WS Close (1008) │
│ │ │
│ ▼ │
│ JWT 验证成功 ──► 绑定 user_id + session_id │
│ │
│ 3. 消息循环 (Message Loop) │
│ Client ──── Envelope ──► Gateway │
│ │ │
│ ▼ │
│ session_id 一致性验证 ──► 处理事件 │
│ │
└─────────────────────────────────────────────────────────────┘
方案 A:Cookie 认证(浏览器环境)
GET /gateway HTTP/1.1
Host: hotplex.example.com
Upgrade: websocket
Connection: Upgrade
Cookie: hotplex_token=<gateway_token>
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13验证逻辑:
func (g *Gateway) ValidateHandshake(r *http.Request) error {
// 1. 检查 TLS
if !g.config.TLS.Required && r.TLS == nil {
return ErrTLSRequired
}
// 2. 验证 Cookie 中的 Gateway Token
cookie, err := r.Cookie("hotplex_token")
if err != nil {
return ErrMissingAuthCookie
}
claims, err := g.validateGatewayToken(cookie.Value)
if err != nil {
return err
}
// 3. 存储用户信息到请求上下文
r = r.WithContext(withUserClaims(r.Context(), claims))
return nil
}方案 B:Authorization Header(CLI/Desktop)
GET /gateway HTTP/1.1
Host: hotplex.example.com
Upgrade: websocket
Connection: Upgrade
Authorization: Bearer <jwt_token>
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13JWT Token 嵌入 Envelope:
{
"version": "aep/v1",
"kind": "init",
"data": {
"protocol_version": "aep/v1",
"client_caps": ["streaming", "tools"],
"auth": {
"token": "<jwt_token>"
}
}
}验证流程:
func (g *Gateway) HandleInit(env *Envelope) (*Envelope, error) {
// 1. 提取 JWT Token
tokenStr := env.Data["auth"].(map[string]interface{})["token"].(string)
// 2. 解析并验证 JWT
claims, err := g.jwtValidator.Validate(tokenStr)
if err != nil {
return nil, &AuthError{Code: "AUTHENTICATION_FAILED", Reason: err.Error()}
}
// 3. 验证 jti 不在黑名单(防重放)
if g.redis.Exists("jwt:blacklist:" + claims.JTI) {
return nil, &AuthError{Code: "TOKEN_REVOKED", Reason: "jti already used"}
}
// 4. 验证 aud
if !slices.Contains(claims.Audience, "hotplex-gateway") {
return nil, &AuthError{Code: "INVALID_AUDIENCE", Reason: "wrong audience"}
}
// 5. 绑定用户到 session
session := sm.CreateSession(claims.Sub, claims.BotID)
return &Envelope{
Kind: "init_ack",
Data: map[string]interface{}{
"session_id": session.ID,
"server_caps": g.getServerCaps(),
},
}, nil
}type Session struct {
ID string
OwnerID string // JWT sub claim
BotID string // JWT bot_id claim
State SessionState
CreatedAt int64
}func (sm *SessionManager) ValidateOwnership(sessionID, userID string) error {
session, err := sm.GetSession(sessionID)
if err != nil {
return ErrSessionNotFound
}
if session.OwnerID != userID {
// 记录安全日志
log.Warn("session ownership mismatch",
"session_id", sessionID,
"expected_owner", session.OwnerID,
"actual_owner", userID,
)
return ErrSessionOwnershipMismatch
}
return nil
}| 端点 | Required Scope | 说明 |
|---|---|---|
GET /admin/sessions |
admin:read |
列出所有 session |
DELETE /admin/sessions/{id} |
admin:delete |
强制终止 |
GET /admin/stats |
admin:read |
统计信息 |
GET /admin/metrics |
admin:read |
Prometheus metrics |
⚠️ 必须使用crypto/rand生成 jti,禁止使用math/rand或时间戳。
// internal/security/jwt.go
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// GenerateJTI 生成符合 RFC 7519 的 JWT ID
// 使用 crypto/rand 确保密码学安全,格式兼容 UUID v4
func GenerateJTI() (string, error) {
b := make([]byte, 16)
n, err := rand.Read(b)
if err != nil {
return "", fmt.Errorf("crypto/rand unavailable: %w", err)
}
if n != 16 {
return "", fmt.Errorf("crypto/rand read insufficient bytes: got %d, want 16", n)
}
// UUID v4 格式:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// 设置版本号(4)和变体(8/9/a/b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
), nil
}// internal/security/key_manager.go
type KeyManager interface {
GetPrivateKey() (*ecdsa.PrivateKey, error)
GetPublicKey() *ecdsa.PublicKey
PublicKeyPEM() ([]byte, error)
Rotate() error
}
// FileKeyManager 从文件加载密钥,支持密钥轮换
type FileKeyManager struct {
privateKeyPath string
publicKeyPath string
privateKey *ecdsa.PrivateKey // 缓存
publicKey *ecdsa.PublicKey // 缓存
mu sync.RWMutex
}
func NewFileKeyManager(privatePath, publicPath string) (*FileKeyManager, error) {
km := &FileKeyManager{
privateKeyPath: privatePath,
publicKeyPath: publicPath,
}
// 预加载密钥
if err := km.loadKeys(); err != nil {
return nil, err
}
return km, nil
}
func (km *FileKeyManager) loadKeys() error {
km.mu.Lock()
defer km.mu.Unlock()
privPEM, err := os.ReadFile(km.privateKeyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
privBlock, _ := pem.Decode(privPEM)
if privBlock == nil {
return errors.New("invalid PEM in private key file")
}
priv, err := x509.ParseECPrivateKey(privBlock.Bytes)
if err != nil {
return fmt.Errorf("parse EC private key: %w", err)
}
km.privateKey = priv
km.publicKey = &priv.PublicKey
return nil
}
func (km *FileKeyManager) GetPrivateKey() (*ecdsa.PrivateKey, error) {
km.mu.RLock()
defer km.mu.RUnlock()
if km.privateKey == nil {
return nil, errors.New("private key not loaded")
}
return km.privateKey, nil
}
func (km *FileKeyManager) GetPublicKey() *ecdsa.PublicKey {
km.mu.RLock()
defer km.mu.RUnlock()
return km.publicKey
}
func (km *FileKeyManager) PublicKeyPEM() ([]byte, error) {
km.mu.RLock()
defer km.mu.RUnlock()
pubDER, err := x509.MarshalPKIXPublicKey(km.publicKey)
if err != nil {
return nil, fmt.Errorf("marshal public key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubDER,
}), nil
}
// Rotate 重新加载密钥(用于密钥轮换时热更新)
func (km *FileKeyManager) Rotate() error {
return km.loadKeys()
}密钥文件格式(PEM + PKCS8 / SEC1):
# 生成 ES256 私钥(P-256 曲线,256-bit)
openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
openssl ec -in private_key.pem -outform PEM -out private_key.pem
# 导出公钥
openssl ec -in private_key.pem -pubout -out public_key.pem
# 验证格式
openssl ec -in private_key.pem -text -noout
# EC Private-Key 曲率为 prime256v1 (NID_X9_62_prime256v1)
⚠️ jti TTL = access_token_ttl × 2,允许时钟偏移(客户端与服务端时钟差 ≤ TTL)。
const (
AccessTokenTTL = 5 * time.Minute
JTIBlacklistTTL = AccessTokenTTL * 2 // 10 分钟,允许 ±5 分钟时钟偏移
)// Access Token 刷新
func (s *AuthService) RefreshToken(refreshToken string) (*TokenPair, error) {
// 1. 验证 Refresh Token
claims, err := s.validateRefreshToken(refreshToken)
if err != nil {
return nil, err
}
// 2. 验证 jti 不在黑名单(Rotation 机制)
if s.redis.Exists("refresh:blacklist:" + claims.JTI) {
return nil, ErrTokenReused // 单次使用
}
// 3. 将旧 jti 加入黑名单
s.redis.Set("refresh:blacklist:"+claims.JTI, "1", 7*24*time.Hour)
// 4. 签发新 Token Pair
return s.issueTokenPair(claims.Sub, claims.BotID)
}Redis Blacklist:
// JWT 吊销
func (s *AuthService) RevokeToken(jti string, ttl time.Duration) error {
return s.redis.Set("jwt:blacklist:"+jti, "revoked", ttl)
}
// 登出时吊销所有 Token
func (s *AuthService) Logout(userID string) error {
// 删除该用户的所有活跃 Token
return s.redis.Del("user:tokens:" + userID)
}✅ 采用共享 ES256 密钥 + JWT
bot_idclaim 隔离,简化密钥分发运维。
// JWT 中包含 bot_id,Gateway 按 bot_id 路由到对应 Worker Pool
type Session struct {
ID string
OwnerID string // JWT sub claim
BotID string // JWT bot_id claim(用于 Worker Pool 隔离)
}为何不用独立密钥:
- 每个 Bot 需要独立密钥对,公钥分发复杂
- 共享密钥 +
bot_id在内部服务间足够安全(外部攻击者无 bot_id)
何时需要独立密钥:
- 多租户场景(每个租户自行管理密钥)
- 参见 v2.0-design 多实例分布式架构
| # | 问题 | 选项 | 推荐 |
|---|---|---|---|
| 1 | Gateway Token TTL | 1小时 / 5分钟 | 1小时(稳定性优先,WebSocket 长连接) |
| 2 | Refresh Token 存储 | HttpOnly Cookie / 客户端存储 | HttpOnly Cookie(浏览器) |
| 3 | 多 Bot 隔离 | 独立密钥 / 共享密钥 + bot_id | 共享密钥 + bot_id(简化管理) |
| 4 | Session Resume | Discord 风格 / 无状态 | 有状态(用户期望) |
# 方案 A:Gateway Token = 1小时(稳定优先)
auth:
gateway_token_ttl: 1h
access_token_ttl: 5m
refresh_token_ttl: 168h # 7 days
# 方案 B:Gateway Token = 5分钟(安全优先,需定期刷新)
auth:
gateway_token_ttl: 5m
access_token_ttl: 5m
refresh_token_ttl: 168h- 使用 ES256 签名(禁止 HS256)
- 验证 JWT
audclaim - 实现 jti 防重放(Redis 黑名单)
- WebSocket 握手阶段 Cookie/Header 认证
- init.envelope 中 JWT 验证
- Session Ownership 绑定
- Token 刷新 + Rotation
- TLS 强制(生产环境)
- 安全日志(认证失败、Ownership 不匹配)
- RFC 7519: JSON Web Token (JWT)
- RFC 8725: JSON Web Algorithms (JWE)
- RFC 6750: OAuth 2.0 Bearer Token Usage
- Discord Gateway Authentication
- [Auth0: JSON Web Token Best Practices](https://auth0.com/blog/ json-web-token-best-practices/)