Files
VibeCoding/WebRTCSignalServer/SECURITY.md
tongtongstudio 6eb2c7321a feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
2026-08-01 15:13:12 +08:00

219 lines
8.7 KiB
Markdown
Raw 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.
# 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. 已实现的安全措施
| 措施 | 说明 |
|---|---|
| 密码存储 | BCryptcost=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` 仍返回全部在线设备,**接入绑定关系后应改为仅返回已绑定设备**。