feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View File

@@ -0,0 +1,218 @@
# 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` 仍返回全部在线设备,**接入绑定关系后应改为仅返回已绑定设备**。