Security: Authentication & Authorization Design

May 22, 2026 · View on GitHub

HotPlex WebSocket 认证授权设计,基于 API Key + Bot ID 模型。


1. 设计原则

1.1 核心决策

决策方案说明
认证方式API Key简单、无状态、适合 WebSocket 长连接
Bot 隔离X-Bot-ID Header请求级隔离,每个 Bot 只能操作自己的 Session
多用户映射APIKeyResolver可选,将 API Key 映射到不同 userID
生产要求至少配置一个 API Key未配置时自动降级为 anonymous(开发模式)

1.2 设计优势

  • 简洁:无需签名/验证 JWT,减少攻击面
  • 无状态:API Key 在内存 map 中验证,支持热重载
  • 无损轮转:编号式环境变量(_1_2)支持在线替换
  • 灵活APIKeyResolver 可按需启用多用户隔离

2. API Key 认证

2.1 密钥传递方式

internal/security/auth.go 提供双层 Key 提取:

  1. HTTP HeaderX-API-Key,可自定义)
  2. Query Parameterapi_key,浏览器 WebSocket 客户端专用)
# 方式 1:HTTP Header(推荐,CLI/服务端)
curl -i --no-buffer \
  -H "X-API-Key: your-api-key" \
  -H "X-Bot-ID: your-bot-id" \
  -H "Upgrade: websocket" \
  -H "Connection: Upgrade" \
  http://localhost:8080/ws

# 方式 2:Query Param(浏览器 WS 客户端)
ws://localhost:8080/ws?api_key=your-api-key&bot_id=your-bot-id

2.2 密钥配置

security:
  api_keys:
    - "ak-xxxxx"
    - "ak-yyyyy"

或通过环境变量(编号式,支持无损轮转):

HOTPLEX_SECURITY_API_KEY_1=ak-xxxxx
HOTPLEX_SECURITY_API_KEY_2=ak-yyyyy

零密钥 = 开发模式:未配置 API Key 时自动降级为 anonymous 用户,生产环境必须配置至少一个 Key

2.3 热重载

Authenticator.ReloadKeys() 支持运行时更新密钥列表,无需重启:

curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://localhost:9999/admin/config/reload

3. Bot ID 隔离

3.1 传递方式

Bot ID 通过以下方式传递:

  1. HTTP HeaderX-Bot-ID: your-bot-id
  2. Query Parameter?bot_id=your-bot-id
  3. init 信封data.bot_id 字段
{
  "event": {
    "type": "init",
    "data": {
      "version": "aep/v1",
      "worker_type": "claude_code",
      "auth": { "token": "your-api-key" },
      "bot_id": "B12345"
    }
  }
}

3.2 提取方式

botID := security.BotIDFromRequest(r)

3.3 隔离规则

  • bot_id 必须与 Session 所属 Bot 精确匹配
  • 跨 Bot 操作被硬拒绝,返回 403 Forbidden
  • 未指定 bot_id 时不执行 Bot 级隔离检查

4. APIKeyResolver(多用户映射)

4.1 默认行为

未设置 APIKeyResolver 时,所有 API Key 认证的请求统一使用 api_user 身份:

所有 API Key → api_user → Session ID 派生不区分用户

4.2 自定义 Resolver

通过 security.SetKeyResolver() 设置自定义映射:

security.SetKeyResolver(func(key string) (userID string, ok bool) {
    // 从数据库/配置/API 查询 key 对应的 userID
    userID, err := db.ResolveKeyToUser(key)
    if err != nil {
        return "", false
    }
    return userID, true
})

设置后,不同 API Key 可映射到不同 userID,实现用户级会话隔离:

key: ak-alice → resolver → userID: "alice" → Session A
key: ak-bob   → resolver → userID: "bob"   → Session B

ListSessions API 按 userID 过滤,每个用户只看到自己的会话。


5. WebSocket 认证流程

5.1 双保险认证机制

┌─────────────────────────────────────────────────────────────┐
│                  WebSocket Authentication                     │
│                                                              │
│  1. 握手阶段 (Handshake)                                     │
│     Client ──── X-API-Key Header ────► Gateway               │
│                    │                                         │
│                    ▼                                         │
│     API Key 无效 ──► 401 Unauthorized                        │
│                    │                                         │
│                    ▼                                         │
│     API Key 有效 ──► 允许 Upgrade (101 Switching Protocols)   │
│                                                              │
│  2. 首条消息认证 (First Message)                              │
│     Client ──── init {auth.token} ────► Gateway               │
│                    │                                         │
│                    ▼                                         │
│     验证失败 ──────► error + WS Close (1008)                 │
│                    │                                         │
│                    ▼                                         │
│     验证成功 ──────► 绑定 userID + botID + session_id        │
│                                                              │
│  3. 消息循环 (Message Loop)                                  │
│     Client ──── Envelope ──► Gateway                         │
│                    │                                         │
│                    ▼                                         │
│     session_id 一致性验证 ──► 处理事件                       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

5.2 握手阶段认证

方案 A:API Key Header(CLI/Desktop)

GET /gateway HTTP/1.1
Host: hotplex.example.com
Upgrade: websocket
Connection: Upgrade
X-API-Key: ak-xxx
X-Bot-ID: bot-123
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

方案 B:Query Parameter(浏览器 WebSocket)

GET /gateway?api_key=ak-xxx&bot_id=bot-123 HTTP/1.1
Host: hotplex.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

5.3 首条消息认证(init.envelope)

API Key 嵌入 Envelope

{
  "version": "aep/v1",
  "kind": "init",
  "data": {
    "protocol_version": "aep/v1",
    "client_caps": ["streaming", "tools"],
    "auth": {
      "token": "your-api-key"
    },
    "bot_id": "bot-123"
  }
}

验证流程

func (g *Gateway) HandleInit(env *Envelope) (*Envelope, error) {
    // 1. 提取 API Key
    tokenStr := env.Data["auth"].(map[string]interface{})["token"].(string)

    // 2. 验证 API Key
    userID, ok := g.auth.Authenticate(tokenStr)
    if !ok {
        return nil, &AuthError{Code: "AUTHENTICATION_FAILED", Reason: "invalid API key"}
    }

    // 3. 使用 APIKeyResolver 映射用户身份(可选)
    if resolver != nil {
        if resolvedID, ok := resolver(tokenStr); ok {
            userID = resolvedID
        }
    }

    // 4. 提取 Bot ID
    botID := env.Data["bot_id"].(string)

    // 5. 创建 Session
    session := sm.CreateSession(userID, botID)

    return &Envelope{
        Kind: "init_ack",
        Data: map[string]interface{}{
            "session_id": session.ID,
            "server_caps": g.getServerCaps(),
        },
    }, nil
}

6. Session Ownership 验证

6.1 Session 绑定

type Session struct {
    ID        string
    OwnerID   string    // userID(来自 APIKeyResolver 或默认 api_user)
    BotID     string    // bot_id(来自 X-Bot-ID 或 init data)
    State     SessionState
    CreatedAt int64
}

6.2 Ownership 验证流程

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
}

6.3 Admin API 权限矩阵

端点Required Scope说明
GET /admin/sessionsadmin:read列出所有 session
DELETE /admin/sessions/{id}admin:delete强制终止
GET /admin/statsadmin:read统计信息
GET /admin/metricsadmin:readPrometheus metrics

7. 密钥轮转

7.1 API Key 无损轮转

编号式环境变量支持在线替换:

# 1. 添加新密钥
export HOTPLEX_SECURITY_API_KEY_2="ak-new-key"

# 2. 所有客户端切换到新密钥

# 3. 移除旧密钥
unset HOTPLEX_SECURITY_API_KEY_1

7.2 Admin Token 轮转(零停机)

# 1. 生成新 Token
NEW_TOKEN=$(openssl rand -base64 32 | tr -d '/+=' | head -c 43)

# 2. 更新 _2(保留 _1)
export HOTPLEX_ADMIN_TOKEN_2="$NEW_TOKEN"

# 3. 所有客户端切换到 _2

# 4. 更新 _1 为新 Token,清除旧 _2

8. 多 Bot 隔离方案

8.1 决策:API Key + X-Bot-ID Header

采用 API Key + Bot ID 隔离,简化密钥分发运维。

// Session 包含 BotID,Gateway 按 BotID 路由
type Session struct {
    ID        string
    OwnerID   string  // userID(来自 APIKeyResolver 或默认 api_user)
    BotID     string  // bot_id(来自 X-Bot-ID Header 或 init data)
}

为何不用 JWT

  • JWT 增加了签名/验证复杂度和攻击面
  • API Key + Bot ID 在内部服务间足够安全
  • 无状态验证,无需密钥轮转机制
  • 编号式环境变量原生支持无损轮转

9. 安全检查清单

  • 生产环境至少配置一个 API Key
  • 配置 X-Bot-ID 实现多 Bot 隔离
  • 使用 APIKeyResolver 实现多用户隔离(可选)
  • WebSocket 握手阶段 Header/Query 认证
  • init.envelope 中 API Key 验证
  • Session Ownership 绑定
  • 编号式环境变量支持密钥轮转
  • TLS 强制(生产环境)
  • 安全日志(认证失败、Ownership 不匹配)