# WebRTCSignalServer 鉴权与账号机制说明

本文档描述已落地的 **WebSocket 握手鉴权** 与 **账号机制**（方案 P0 阶段）。

> **重大变更**：`/ws/signal` 不再接受匿名连接。所有客户端必须先获取访问令牌，
> 并在握手时携带，否则握手将以 HTTP 401 被拒绝。

---

## 1. 身份模型

两端能力不对称，因此采用双轨身份：

| | 主控端 | 被控端 |
|---|---|---|
| 身份根 | 用户账号（用户名 + 密码） | 设备 SN（系统签名应用可靠获取） |
| 凭据 | accessToken + refreshToken | deviceSecret → 短期 deviceToken |
| 信令 ID | `ctl_<sessionId>`（服务端派生） | `dev_<22位随机>`（高熵不可枚举） |
| 信令角色 | `CONTROLLER` | `CONTROLLED` |

**关键设计**：SN 只作服务端内部主键，绝不作为公网可寻址 ID 暴露；
对外一律使用随机生成的 `deviceUid`，杜绝通过猜测 SN 定位并骚扰被控端。

---

## 2. 主控端接入流程

```
POST /api/auth/register   { username, password }        # 可关闭
POST /api/auth/login      { username, password }        # -> accessToken / refreshToken
     ↓
WebSocket 握手（携带 accessToken）
     ↓
POST /api/auth/refresh    { refreshToken }              # accessToken 过期前刷新
```

### 接口一览

| 方法 | 路径 | 说明 | 需认证 |
|---|---|---|---|
| POST | `/api/auth/register` | 注册（受 `ACCOUNT_REGISTRATION_ENABLED` 控制） | 否 |
| POST | `/api/auth/login` | 登录，返回令牌对 | 否 |
| POST | `/api/auth/refresh` | 刷新并轮转令牌 | 否 |
| POST | `/api/auth/logout` | 登出当前会话 | 是 |
| POST | `/api/auth/logout-all` | 全端登出 | 是 |
| POST | `/api/auth/change-password` | 改密（成功后强制全端重登） | 是 |
| GET | `/api/auth/me` | 查询当前身份 | 是 |
| GET | `/api/auth/sessions` | 查询本账号活跃会话 | 是 |

认证方式：`Authorization: Bearer <accessToken>`

---

## 3. 被控端接入流程

被控端无法登录账号，改用「SN + 内置共享密钥 HMAC」激活：

```
POST /api/device/provision  { sn, model, nonce, timestamp, hmac }
     ↓ 返回 deviceUid + deviceSecret（仅此一次明文返回）
     ↓ deviceSecret 存入 Android Keystore
POST /api/device/token      { deviceUid, deviceSecret }
     ↓ 返回短期 deviceToken（默认 15 分钟）
WebSocket 握手（携带 deviceToken）
```

### HMAC 计算方式

```
hmac = HexLowercase( HMAC-SHA256( DEVICE_PROVISION_SECRET, sn + "|" + nonce + "|" + timestamp ) )
```

- `timestamp`：Unix 秒；与服务端偏差超过 `provision-skew-seconds`（默认 300s）即拒绝
- `nonce`：每次激活唯一（建议 UUID），服务端做重放检测
- `DEVICE_PROVISION_SECRET`：内置于系统签名 APK，建议配合 R8/NDK 加固

激活安全校验链：`时间戳窗口 → nonce 防重放 → HMAC 签名 → SN 白名单`

同一 SN 重复激活会**轮换 deviceSecret 并使旧令牌立即失效**（记录 WARN 日志）。

---

## 4. WebSocket 握手鉴权

### 令牌传递方式（按优先级）

1. **推荐**（不会被写入访问日志）：
   ```
   Sec-WebSocket-Protocol: signal.v1, auth.<token>
   ```
   服务端会回显 `signal.v1` 完成协商，**绝不回显携带令牌的那一项**。

2. 非浏览器客户端：
   ```
   Authorization: Bearer <token>
   ```

3. 兼容方式（**不推荐**，令牌可能进入网关/代理日志，服务端会打印告警）：
   ```
   /ws/signal?token=<token>
   ```

### 服务端行为

- 握手阶段完成鉴权，失败直接返回 **401**，不建立连接
- 单 IP 握手限流：60 秒内最多 30 次，超限返回 **429**
- 认证通过后，连接建立即自动完成注册并下发 `REGISTER_SUCCESS`，
  **客户端无需再发送 `REGISTER`**（旧客户端仍发送时会收到同样的响应，不会报错）
- 消息中的 `fromDeviceId` / `deviceType` **一律被服务端鉴权结果覆盖**，
  客户端伪造无效（不一致时记录 WARN 日志）

### 关闭码

| 码 | 含义 |
|---|---|
| 4001 | 未认证 / 认证信息缺失 |
| 4003 | 被强制下线（封禁、踢出、设备禁用） |

被强制下线前，服务端会先下发一条消息：
```json
{ "type": "FORCE_LOGOUT", "payload": "<原因>" }
```

---

## 5. 封禁与强制下线

令牌失效采用 **凭据版本号（tokenVersion）** 机制：账号/设备被封禁时版本号递增，
所有已签发的令牌**立即失效**，无需维护黑名单表。同时服务端主动关闭其 WebSocket 连接。

| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/admin/users` | 账号列表 |
| GET | `/api/admin/users/{userId}/sessions` | 账号活跃会话 |
| POST | `/api/admin/users/{userId}/ban` | 封禁（`durationSeconds` 缺省为永久） |
| POST | `/api/admin/users/{userId}/unban` | 解封 |
| POST | `/api/admin/users/{userId}/kick` | 全端强制下线（不改封禁状态） |
| POST | `/api/admin/sessions/{sessionId}/kick` | 踢出单个会话 |
| GET | `/api/admin/device-accounts` | 设备列表（SN 已脱敏） |
| POST | `/api/admin/device-accounts/{uid}/disable` | 禁用设备 |
| POST | `/api/admin/device-accounts/{uid}/enable` | 启用设备 |
| POST | `/api/admin/device-allowlist` | 批量导入 SN 白名单 |

管理接口凭据（二选一）：
- `X-Admin-Token: <ADMIN_TOKEN>`（兼容既有管理后台）
- `Authorization: Bearer <accessToken>` 且账号具备管理员角色

---

## 6. 已实现的安全措施

| 措施 | 说明 |
|---|---|
| 密码存储 | BCrypt（cost=12） |
| 防账号枚举 | 用户名不存在时执行伪哈希抹平时间差，错误提示统一 |
| 登录防爆破 | 连续失败 5 次锁定 15 分钟；单 IP 5 分钟最多 10 次 |
| 刷新令牌轮转 | 每次刷新更换令牌；**检测到旧令牌复用即判定泄露并吊销整个会话** |
| 令牌用途隔离 | access / refresh / device 三类令牌互不通用 |
| 签名比较 | 使用 `MessageDigest.isEqual` 常量时间比较，防时序侧信道 |
| 并发会话限制 | 默认最多 5 个，超限自动踢最旧会话 |
| 激活防重放 | 时间戳窗口 + nonce 唯一性校验 |
| 日志脱敏 | SN 仅打印后 4 位 |
| 错误信息模糊化 | 对外统一提示，详细原因仅记录服务端日志 |
| 空闲超时 | 90 秒（原 10 分钟），尽早回收失联连接 |
| Origin 收敛 | 由 `WS_ALLOWED_ORIGINS` 配置，不再硬编码 `*` |

---

## 7. 配置项

**生产环境必须通过环境变量配置以下三项：**

| 环境变量 | 说明 |
|---|---|
| `JWT_SECRET` | JWT 签名密钥，**至少 32 字节**。未配置时随机生成，重启后令牌全部失效且多实例无法互认 |
| `DEVICE_PROVISION_SECRET` | 设备激活共享密钥。**未配置时所有激活请求会被拒绝** |
| `ADMIN_TOKEN` | 管理后台令牌 |

其他可选项：

| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | 初始管理员用户名 |
| `BOOTSTRAP_ADMIN_PASSWORD` | 空 | 初始管理员密码，**为空则不创建账号** |
| `ACCOUNT_REGISTRATION_ENABLED` | `true` | 是否开放自助注册 |
| `DEVICE_SN_ALLOWLIST_ENABLED` | `false` | 是否启用 SN 白名单 |
| `WS_ALLOWED_ORIGINS` | `*` | WebSocket 允许来源，生产应收敛为具体域名 |

---

## 8. 客户端适配清单（待办）

服务端已强制鉴权，以下客户端需相应改造，否则将无法连接：

| 客户端 | 需要的改动 |
|---|---|
| `WebRTCControlled`（被控端） | 获取 SN → 调用 provision → 存储 deviceSecret → 换取 token → 握手携带 |
| `WebRTCController`（主控端 Android） | 增加登录界面 → 保存令牌 → 握手携带 → 处理 401/4003 |
| `WebRTCControllerWeb` | 同上，浏览器端建议使用 `Sec-WebSocket-Protocol` 方式 |
| `webrtc_controller_flutter` | 同上 |
| `webrtc_controller_ios` | 同上 |

各端还需统一处理：
- `FORCE_LOGOUT` 消息与 4001 / 4003 关闭码（提示用户并跳转登录）
- 访问令牌过期前静默刷新（建议在剩余 1/3 有效期时触发）
- 不再发送 `REGISTER`（发送也不会出错，但已无意义）

---

## 9. 当前实现边界

- **存储为内存实现**：`AccountService` / `DeviceIdentityService` 使用
  `ConcurrentHashMap`，**服务重启后账号与设备数据会丢失**。接口已按持久化预留，
  后续可替换为 MySQL 仓储而不影响调用方。
- **限流为单机实现**：多实例部署需替换为 Redis 计数，并通过 Pub/Sub 广播踢线事件。
- **尚未实现**（属方案 P1/P2 阶段）：绑定关系寻址、配对码、被控端拉黑、
  勿扰时段、审计日志、TOTP、TURN 短期凭据。
  当前 `DEVICE_LIST` 仍返回全部在线设备，**接入绑定关系后应改为仅返回已绑定设备**。
