# WebRTCSignalServer 鉴权与账号机制说明 本文档描述已落地的 **WebSocket 握手鉴权** 与 **账号机制**(方案 P0 阶段)。 > **重大变更**:`/ws/signal` 不再接受匿名连接。所有客户端必须先获取访问令牌, > 并在握手时携带,否则握手将以 HTTP 401 被拒绝。 --- ## 1. 身份模型 两端能力不对称,因此采用双轨身份: | | 主控端 | 被控端 | |---|---|---| | 身份根 | 用户账号(用户名 + 密码) | 设备 SN(系统签名应用可靠获取) | | 凭据 | accessToken + refreshToken | deviceSecret → 短期 deviceToken | | 信令 ID | `ctl_`(服务端派生) | `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 ` --- ## 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. ``` 服务端会回显 `signal.v1` 完成协商,**绝不回显携带令牌的那一项**。 2. 非浏览器客户端: ``` Authorization: Bearer ``` 3. 兼容方式(**不推荐**,令牌可能进入网关/代理日志,服务端会打印告警): ``` /ws/signal?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: `(兼容既有管理后台) - `Authorization: Bearer ` 且账号具备管理员角色 --- ## 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` 仍返回全部在线设备,**接入绑定关系后应改为仅返回已绑定设备**。