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,700 @@
# 客户端对接接口规范 v1
面向 5 个客户端:`WebRTCControlled`(被控端)、`WebRTCController`(主控端 Android
`WebRTCControllerWeb``webrtc_controller_flutter``webrtc_controller_ios`
> **破坏性变更**`/ws/signal` 不再接受匿名连接。未携带有效令牌的握手将被 **HTTP 401** 拒绝。
> 各端必须完成本文档的改造才能连接。
---
## 0. 基础约定
### 0.1 地址
现有各端硬编码的信令地址为 `wss://www.ttstd.com/signal`(经反向代理)。
新增的 HTTP 接口位于同域下的 `/api/**`
| 用途 | 地址 |
|---|---|
| HTTP API 基址 | `https://www.ttstd.com` |
| WebSocket 信令 | `wss://www.ttstd.com/signal` |
> **运维需确认**:反向代理需将 `/api/**` 转发到信令服务的 `/api/**`
> 且 `/signal` 转发到 `/ws/signal`。若代理仅配置了 `/signal`,需补充 `/api` 规则。
建议各端把「HTTP 基址」和「WS 地址」拆成两个可配置项,而非从 WS 地址推导。
### 0.2 通用响应
成功:`200 OK`JSON body。
失败:
```json
{ "code": 401, "error": "UNAUTHORIZED", "message": "认证失败" }
```
> 服务端对外只返回**模糊提示**(不区分"账号不存在/密码错误"、"设备离线/被拉黑"
> 详细原因仅记录在服务端日志。客户端不要试图解析 message 做业务分支,**请以 `error` 字段为准**。
### 0.3 通用错误码
| HTTP | error | 客户端应对 |
|---|---|---|
| 400 | `BAD_REQUEST` | 参数问题,提示用户修正 |
| 401 | `UNAUTHORIZED` | 令牌无效/过期 → 尝试刷新,失败则跳登录 |
| 403 | `FORBIDDEN` | 账号被封禁/设备被禁用 → 提示并登出 |
| 429 | `TOO_MANY_REQUESTS` | 触发限流 → 退避重试,勿立即重连 |
---
## 1. 主控端接口Controller / Web / Flutter / iOS
### 1.1 注册
```
POST /api/auth/register
Content-Type: application/json
{ "username": "alice", "password": "Passw0rd!" }
```
响应 `200`
```json
{ "userId": "usr_xxxxxxxxxxxxxxxxxxxx", "username": "alice" }
```
约束:
- 用户名3-32 位,`[a-zA-Z0-9_.-]`
- 密码8-128 位,且至少包含「大写 / 小写 / 数字 / 符号」中的两类
- 限流:单 IP 每小时 5 次
- 可由服务端配置关闭(`ACCOUNT_REGISTRATION_ENABLED=false`),关闭时返回 403
---
### 1.2 登录
```
POST /api/auth/login
{ "username": "alice", "password": "Passw0rd!" }
```
响应 `200`
```json
{
"accessToken": "eyJhbGc...",
"refreshToken": "ses_xxxx.AbCdEf...",
"expiresIn": 900,
"sessionId": "ses_xxxxxxxxxxxxxxxxxxxx",
"principalId": "usr_xxxxxxxxxxxxxxxxxxxx",
"displayName": "alice"
}
```
要点:
- `accessToken` 有效期 **900 秒**,用于 HTTP 与 WebSocket 握手
- `refreshToken` 有效期 **7 天**,格式为 `<sessionId>.<secret>`**必须整体保存**
- 限流:单 IP 5 分钟内 10 次;连续密码错误 5 次锁定账号 15 分钟(返回 429
> **开启 TOTP 的账号**:若响应为 `401` 且 `error` 为 `TOTP_REQUIRED`
> 需引导用户输入 6 位动态码,并带 `totpCode` 字段重新请求登录。详见 §1.8。
**存储要求**
| 端 | accessToken | refreshToken |
|---|---|---|
| Android | 内存 | EncryptedSharedPreferences |
| iOS | 内存 | **Keychain**(勿用 UserDefaults |
| Flutter | 内存 | `flutter_secure_storage` |
| Web | 内存变量 | `sessionStorage`(勿用 localStorage |
---
### 1.3 刷新令牌
```
POST /api/auth/refresh
{ "refreshToken": "ses_xxxx.AbCdEf..." }
```
响应结构同登录。
**关键规则(务必遵守)**
- 刷新令牌**一次性**,每次刷新都会返回新的,**必须立即覆盖保存旧值**
- 服务端有**复用检测**:若用已被使用过的旧 refreshToken 再次请求,
会判定为令牌泄露并**吊销整个会话**(该账号在此设备上需重新登录)
- 因此**严禁并发刷新**。请加互斥锁/单飞single-flight
多个请求同时遇到 401 时只允许一个发起刷新,其余等待其结果
**刷新时机**:建议在 `accessToken` 剩余有效期不足 1/3即约 300 秒)时主动刷新,
不要等到 401 才被动刷新WebSocket 握手失败重连成本更高)。
---
### 1.4 登出
```
POST /api/auth/logout
Authorization: Bearer <accessToken>
```
```
POST /api/auth/logout-all # 全端登出
Authorization: Bearer <accessToken>
```
响应:`{ "success": true }`
登出后应清空本地令牌并断开 WebSocket。
---
### 1.5 修改密码
```
POST /api/auth/change-password
Authorization: Bearer <accessToken>
{ "oldPassword": "...", "newPassword": "..." }
```
响应:`{ "success": true, "message": "密码已修改,请重新登录" }`
> 成功后服务端会**吊销全部会话**,客户端必须清空令牌并跳转登录页。
---
### 1.6 查询当前身份
```
GET /api/auth/me
Authorization: Bearer <accessToken>
```
```json
{
"principalId": "usr_xxx",
"principalType": "USER",
"displayName": "alice",
"sessionId": "ses_xxx",
"signalDeviceId": "ctl_ses_xxx",
"admin": false
}
```
`signalDeviceId` 即该连接在信令网络中的 ID**由服务端派生,客户端不可自定义**。
---
### 1.7 会话管理
```
GET /api/auth/sessions
Authorization: Bearer <accessToken>
```
```json
[
{
"sessionId": "ses_xxx",
"ip": "1.2.3.4",
"userAgent": "okhttp/4.12.0",
"createdAt": 1730000000000,
"lastSeenAt": 1730000600000,
"current": true
}
]
```
可用于「登录设备管理」界面,配合 §1.4 的 logout-all 实现异地下线。
---
### 1.8 TOTP 双因子(可选启用)
```
POST /api/auth/totp/setup # 生成密钥,返回 otpauth:// URI 供扫码
Authorization: Bearer <accessToken>
```
```json
{ "secret": "JBSWY3DP...", "otpauthUri": "otpauth://totp/...", "notice": "..." }
```
```
POST /api/auth/totp/enable # 输入一次动态码完成绑定
{ "code": "123456" }
```
```
POST /api/auth/totp/disable # 需当前密码 + 动态码
{ "password": "...", "code": "123456" }
```
启用后,`/api/auth/login` 需附带 `totpCode`
```json
{ "username": "alice", "password": "Passw0rd!", "totpCode": "123456" }
```
---
### 1.9 令牌校验(建立长连前的预检)
客户端在发起 WebSocket 握手前,可先调用此接口确认令牌未过期 / 未失效,
避免握手阶段被服务端直接断开(关闭码 4001。用户令牌与设备令牌均可使用。
```
GET /api/client/verify
Authorization: Bearer <token>
```
有效时响应 `200`
```json
{
"valid": true,
"principalType": "DEVICE", // USER 或 DEVICE
"principalId": "dev_xxx",
"displayName": "Pixel 3",
"expiresAt": 1730000900, // 令牌过期时间(秒)
"remainingSeconds": 812, // 剩余有效秒数
"serverTime": 1730000088
}
```
无效时响应 `200`HTTP 仍为 200业务字段辨状态避免泄露令牌是否存在
```json
{ "valid": false, "error": "UNAUTHORIZED", "message": "认证失败" }
```
> 客户端策略:进入前台或准备重连时调用;若 `remainingSeconds` 低于 `accessTokenTtlSeconds * 0.33`
> 用户端走 `/api/auth/refresh`,设备端重新执行 `/api/device/token`。
---
### 1.10 设备自助信息(被控端)
被控端用自身设备令牌查询当前状态与在线情况(需 `DEVICE` 令牌)。
```
GET /api/client/device/me
Authorization: Bearer <deviceToken>
```
```json
{
"deviceUid": "dev_xxx",
"model": "Pixel 3",
"status": "ACTIVE", // ACTIVE / SUSPENDED / BANNED
"usable": true, // 是否被禁用false 时无法建立信令连接)
"provisionedAt": 1729000000,
"lastOnlineAt": 1730000088,
"statusReason": null // 被封禁/暂停原因(如有)
}
```
> 若 `usable == false`,客户端应停止尝试连接并提示用户设备已被禁用。
---
### 1.11 握手指引(自配置)
返回 WebSocket 握手所需的地址、子协议、关闭码与刷新阈值,便于各端在无硬编码的前提下自配置。
```
GET /api/client/ws-info
```
```json
{
"wsPath": "/ws/signal",
"subprotocol": "signal.v1",
"tokenMethods": [
"header:Authorization Bearer <token>",
"subprotocol:Sec-WebSocket-Protocol: signal.v1, auth.<token>",
"query:?token=<token>"
],
"closeCodes": { "NORMAL": 1000, "UNAUTHORIZED": 4001, "FORCE_LOGOUT": 4003 },
"idleTimeoutSeconds": 90,
"recommendedRefreshRatio": 0.33,
"accessTokenTtlSeconds": 900,
"deviceTokenTtlSeconds": 900
}
```
---
### 1.12 绑定关系与黑名单(防骚扰核心)
被控端(设备)与主控端(用户账号)之间**必须先建立绑定**主控端才能向其发起连接OFFER
即便已绑定,被控端仍可将某主控端加入**黑名单**黑名单优先级高于绑定OFFER 会被服务端直接拒绝。
**默认行为(服务端强制)**
- 未绑定 → OFFER 立即被拒,回送 `REQUEST_ERROR``"未与该设备建立绑定关系,无法发起连接"`
- 已拉黑 → OFFER 立即被拒,回送 `REQUEST_ERROR``"该设备已拒绝来自你的连接"`
- `DEVICE_LIST` 不再返回全局设备清单,主控端仅能看到自己已绑定设备的在线状态(消除被控端枚举)。
#### 被控端自助管理(设备令牌,`Authorization: Bearer <deviceToken>`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/api/client/device/bind` | 按用户名绑定某主控端:`{"username":"alice","alias":"客厅电视"}` |
| POST | `/api/client/device/unbind` | 解绑:`{"username":"alice"}` |
| POST | `/api/client/device/blacklist` | 拉黑:`{"username":"alice","reason":"骚扰"}` |
| POST | `/api/client/device/unblacklist` | 解除拉黑:`{"username":"alice"}` |
| GET | `/api/client/device/relations` | 查看本设备的绑定与黑名单列表 |
#### 主控端自助查询(用户令牌)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/client/bindings` | 查看自己已绑定的设备及其在线状态 |
#### 管理员接口(`/api/admin`,需管理员令牌)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/admin/devices/{deviceUid}/bindings` | 设备全部绑定关系 |
| GET | `/api/admin/devices/{deviceUid}/blacklist` | 设备黑名单 |
| POST | `/api/admin/devices/{deviceUid}/bind` | 建立绑定:`{"username","role":"MEMBER|OWNER","alias"}` |
| POST | `/api/admin/devices/{deviceUid}/unbind` | 解绑:`{"username"}` |
| POST | `/api/admin/devices/{deviceUid}/blacklist` | 拉黑:`{"username","reason"}` |
| POST | `/api/admin/devices/{deviceUid}/unblacklist` | 解除拉黑:`{"username"}` |
> 绑定由「被控端自助」或「管理员」创建;解绑为软删除(置 `REVOKED`),重新绑定可恢复。
---
### 1.13 配对码、TURN 凭证与骚扰举报
#### 配对码(建立绑定的用户友好入口)
被控端生成一次性配对码8 位、去除易混淆字符、10 分钟有效、单次使用、错误 5 次失效),主控端输入码即建立绑定(无需管理员介入)。
| 方法 | 路径 | 令牌 | 说明 |
| --- | --- | --- | --- |
| POST | `/api/client/device/pairing-code` | 设备 | 生成配对码,返回 `{"code":"A2B9K7M4","expiresInSeconds":600}` |
| POST | `/api/client/pairing/redeem` | 用户 | 兑换:`{"code":"A2B9K7M4"}`,成功返回绑定信息 |
```bash
# 被控端生成
curl -X POST https://www.ttstd.com/api/client/device/pairing-code \
-H "Authorization: Bearer $DEVICE_TOKEN"
# 主控端兑换
curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"code":"A2B9K7M4"}'
```
#### TURN 短期凭证中继RFC 7635 风格)
客户端在 `new RTCPeerConnection` 前调用,获取时限内有效的 TURN 用户名/口令HMAC-SHA1 由服务端密钥签名,到期需重新获取)。是否启用由服务端 `security.turn.enabled` 决定;`/api/client/ws-info``turnEnabled` 字段告知客户端。
| 方法 | 路径 | 令牌 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `iceServers`(含 urls/username/credential`expiresAt``ttlSeconds` |
```json
{
"iceServers": [
{ "urls": "turn:turn.ttstd.com:3478?transport=udp",
"username": "1730000000:ab12cd34ef56",
"credential": "Base64(HMAC-SHA1(sharedSecret, username))" }
],
"expiresAt": 1730003600,
"ttlSeconds": 3600
}
```
> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。
#### 骚扰举报P2 风控)
被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。
| 方法 | 路径 | 令牌 | 说明 |
| --- | --- | --- | --- |
| POST | `/api/client/device/report` | 设备 | 举报:`{"username":"alice","reason":"持续骚扰"}` |
| GET | `/api/admin/abuse-reports` | 管理员 | 列表,支持 `status=PENDING` / `reportedUserId=` 过滤 |
| POST | `/api/admin/abuse-reports/{id}/handle` | 管理员 | 处理:`{"status":"HANDLED"}``DISMISSED` |
---
## 2. 被控端接口WebRTCControlled
被控端为系统签名应用(`android:sharedUserId="android.uid.system"`
可通过 `DeviceUtils.getSerial()` 稳定获取 SN但无法登录账号。
因此采用「SN + 内置密钥 HMAC」激活。
### 2.1 首次激活
```
POST /api/device/provision
{
"sn": "ABC123456789",
"model": "Pixel 3",
"nonce": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1730000000,
"hmac": "3f2a...64 位小写 hex"
}
```
**HMAC 计算**
```
hmac = HexLowercase( HMAC-SHA256( DEVICE_PROVISION_SECRET, sn + "|" + nonce + "|" + timestamp ) )
```
- `timestamp`Unix **秒**(非毫秒),与服务端偏差超过 300 秒即拒绝
- `nonce`:每次请求唯一(建议 UUID服务端做重放检测
- `DEVICE_PROVISION_SECRET`:内置于被控端 APK建议放 NDK 层并配合 R8 混淆
响应 `200`
```json
{
"deviceUid": "dev_xxxxxxxxxxxxxxxxxxxxxx",
"deviceSecret": "AbCdEf...",
"notice": "deviceSecret 仅返回一次,请立即安全存储"
}
```
> **`deviceSecret` 仅此一次明文返回**,必须立即写入 Android Keystore
> (或 EncryptedSharedPreferences。丢失后只能重新激活。
**幂等性**:同一 SN 重复激活会复用同一 `deviceUid`,但会**轮换 deviceSecret 并使旧令牌立即失效**。
因此客户端应先检查本地是否已有 `deviceSecret`**有则跳过激活**,避免自己把自己踢下线。
限流:单 IP 每小时 10 次。
### 2.2 换取访问令牌
```
POST /api/device/token
{ "deviceUid": "dev_xxx", "deviceSecret": "AbCdEf..." }
```
```json
{
"accessToken": "eyJhbGc...",
"refreshToken": null,
"expiresIn": 900,
"principalId": "dev_xxx",
"displayName": "Pixel 3"
}
```
设备令牌**没有 refreshToken**,过期后直接用 `deviceSecret` 重新换取即可。
建议在剩余有效期不足 1/3 时提前换取。
限流:单 IP 每小时 60 次。
### 2.3 被控端启动流程
```
读取本地 deviceSecret
├─ 不存在 → getSerial() → POST /api/device/provision → 存储 deviceUid + deviceSecret
└─ 已存在 → 跳过
POST /api/device/token → accessToken
WebSocket 握手(携带 accessToken
收到 REGISTER_SUCCESS其中 deviceId 即服务端分配的 deviceUid
```
> **重要**被控端界面上原本让用户手输的「设备ID」输入框应当**移除或改为只读展示**。
> 设备身份现在完全由 SN 激活决定,不再由用户输入。
---
## 3. WebSocket 握手改造(所有端)
### 3.1 令牌传递方式
**方式 A —— 推荐Web 端必用)**:子协议
```
Sec-WebSocket-Protocol: signal.v1, auth.<token>
```
服务端会回显 `signal.v1` 完成协商,**不会回显含令牌的那一项**。
优点:令牌不进入 URL不会被网关/代理写入访问日志。
**方式 B —— 原生客户端可用**:请求头
```
Authorization: Bearer <token>
```
**方式 C —— 兼容,不推荐**:查询参数
```
wss://www.ttstd.com/signal?token=<token>
```
服务端会打印告警。仅在前两种都无法实现时使用。
### 3.2 各端实现要点
**AndroidOkHttp** — 两端通用:
```java
Request request = new Request.Builder()
.url(serverUrl)
.addHeader("Authorization", "Bearer " + accessToken)
.build();
client.newWebSocket(request, listener);
```
**Web浏览器 WebSocket** — 浏览器无法设置请求头,**必须用子协议**
```js
const ws = new WebSocket(serverUrl, ['signal.v1', 'auth.' + accessToken]);
```
**Flutterweb_socket_channel**
```dart
// IOWebSocketChannel 支持 headers移动端
final channel = IOWebSocketChannel.connect(
Uri.parse(serverUrl),
headers: {'Authorization': 'Bearer $accessToken'},
);
// 若需兼容 Flutter Web改用子协议
// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.$accessToken']);
```
**iOSURLSessionWebSocketTask**
```swift
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let task = session.webSocketTask(with: request)
```
### 3.3 连接建立后
**不再需要发送 `REGISTER`**。认证通过后服务端自动完成注册并主动下发:
```json
{
"type": "REGISTER_SUCCESS",
"deviceId": "ctl_ses_xxx",
"deviceType": "CONTROLLER",
"displayName": "alice"
}
```
客户端应以此消息中的 `deviceId` 作为自己的信令 ID。
> 旧客户端继续发送 `REGISTER` 不会报错(服务端仅回显同样的响应),但已无实际意义。
### 3.4 身份字段不再可信
消息中的 `fromDeviceId``deviceType` **会被服务端用鉴权结果强制覆盖**
客户端填写任意值都无效(不一致时服务端记录 WARN 日志)。发送消息时可继续填写,也可省略。
### 3.5 关闭码处理
| 关闭码 | 含义 | 客户端应对 |
|---|---|---|
| 4001 | 未认证 / 令牌无效 | 刷新令牌后重连;刷新失败则跳登录 |
| 4003 | 被强制下线(封禁/踢出/设备禁用) | **停止自动重连**,提示用户,清空令牌跳登录 |
| 1000/1001 | 正常关闭 | 按既有逻辑 |
| 其他 | 网络异常 | 指数退避重连 |
**特别注意**:被控端 `WebRTCControlled` 现有的自动重连是指数退避2s 起,上限 30s
收到 **4003** 时必须**关闭自动重连**,否则会造成无效重连风暴。
### 3.6 FORCE_LOGOUT 消息
服务端在强制断开前会先下发:
```json
{ "type": "FORCE_LOGOUT", "payload": "账号已被封禁:违规操作" }
```
客户端应展示 `payload` 给用户,然后清理本地状态。
### 3.7 握手限流
单 IP **60 秒内最多 30 次**握手,超限返回 **429**
各端重连必须使用指数退避,避免触发。
---
## 4. 各端改造清单
### 4.1 WebRTCControlled被控端
| 项 | 说明 |
|---|---|
| 新增 | HTTP 客户端(复用已有 OkHttp调用 provision / token |
| 新增 | `DeviceUtils.getSerial()` 接入激活流程(代码已存在,当前被注释) |
| 新增 | Keystore / EncryptedSharedPreferences 存储 `deviceUid` + `deviceSecret` |
| 新增 | 令牌过期前自动换取 |
| 修改 | `WebSocketClient` 构造增加 token握手加 `Authorization` 头 |
| 修改 | 移除/只读化「设备ID」输入框`activity_main.xml` 中硬编码的 `981964879` 应删除) |
| 修改 | 收到 4003 时停止自动重连 |
| 修改 | 服务器地址持久化(当前每次启动重置) |
### 4.2 WebRTCController主控端 Android
| 项 | 说明 |
|---|---|
| 新增 | 登录界面(作为 launcher登录后再进主界面 |
| 新增 | HTTP 客户端调用 login / refresh |
| 新增 | EncryptedSharedPreferences 存 refreshToken |
| 新增 | 令牌自动刷新(加单飞锁) |
| 修改 | `WebSocketClient` 握手加 `Authorization` 头 |
| 修改 | 移除「设备ID」输入框改用 `REGISTER_SUCCESS` 返回值 |
| 修改 | **补充心跳与重连**(当前完全没有,仅被控端有) |
| 修改 | 处理 4001 / 4003 / FORCE_LOGOUT |
### 4.3 WebRTCControllerWeb
| 项 | 说明 |
|---|---|
| 新增 | 登录页组件 + `authStore`(沿用现有 `reactive` 风格,无需引入 Pinia |
| 新增 | `fetch` 封装(当前无任何 HTTP 能力),含 401 自动刷新 |
| 新增 | `sessionStorage` 存 refreshToken |
| 修改 | `SignalingClient.js` 用**子协议**传令牌(浏览器无法设请求头) |
| 修改 | 移除 `App.vue``'web-' + random` 的临时 deviceId 生成 |
| 修改 | 处理关闭码与 FORCE_LOGOUT |
### 4.4 webrtc_controller_flutter
| 项 | 说明 |
|---|---|
| 新增依赖 | `http`(或 `dio`+ `flutter_secure_storage` |
| 新增 | 登录页 + 令牌管理 |
| 修改 | `signaling_client.dart` 改用 `IOWebSocketChannel.connect(..., headers:)` |
| 修改 | 移除 `DeviceUtils.getSerialNumber()` 作为 deviceId 的逻辑(主控端身份来自账号) |
| 修改 | 处理关闭码与 FORCE_LOGOUT |
### 4.5 webrtc_controller_ios
| 项 | 说明 |
|---|---|
| 新增 | 登录视图 + `AuthService`URLSession HTTP |
| 新增 | **Keychain** 存 refreshToken当前 `DeviceUtils` 用的是 UserDefaults不适合存凭据 |
| 修改 | `SignalingClient.swift``URLRequest` + `Authorization` 头 |
| 修改 | 移除 `DeviceUtils.deviceId()` 作为信令 ID 的逻辑 |
| 修改 | 处理关闭码与 FORCE_LOGOUT |
---
## 5. 联调顺序建议
1. **先确认反向代理**已放行 `/api/**`
2. 服务端配置 `JWT_SECRET``DEVICE_PROVISION_SECRET``BOOTSTRAP_ADMIN_PASSWORD`
3. 用 curl 验证 login / provision 流程跑通
4. 改造**被控端**(无 UI 依赖,最容易验证)
5. 改造**Web 端**(调试最快,可验证子协议方式)
6. 改造 Android 主控端 → Flutter → iOS
### curl 自测示例
```bash
# 注册
curl -X POST https://www.ttstd.com/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"tester","password":"Passw0rd!"}'
# 登录
curl -X POST https://www.ttstd.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"tester","password":"Passw0rd!"}'
# 设备激活timestamp 用当前 Unix 秒hmac 需按 §2.1 计算)
curl -X POST https://www.ttstd.com/api/device/provision \
-H 'Content-Type: application/json' \
-d '{"sn":"TESTSN001","model":"test","nonce":"'$(uuidgen)'","timestamp":'$(date +%s)',"hmac":"..."}'
```

View File

@@ -0,0 +1,284 @@
-- =============================================================
-- WebRTCSignalServer 数据库建表脚本 (MySQL 8.0+)
--
-- 【请手动执行,服务端代码当前仍为内存实现,不会自动建表】
--
-- 执行方式:
-- mysql -u root -p < schema.sql
--
-- 字符集统一使用 utf8mb4 / utf8mb4_0900_ai_ci
-- 所有时间字段使用 DATETIME(3),由应用层写入 UTC 时间
-- =============================================================
CREATE DATABASE IF NOT EXISTS `webrtc_signal`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_0900_ai_ci;
USE `webrtc_signal`;
-- =============================================================
-- 1. 主控端用户账号
-- =============================================================
CREATE TABLE IF NOT EXISTS `app_user` (
`user_id` VARCHAR(32) NOT NULL COMMENT '用户ID格式 usr_<20位Base62>',
`username` VARCHAR(32) NOT NULL COMMENT '登录名',
`password_hash` VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希cost=12',
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'
COMMENT '账号状态ACTIVE/SUSPENDED/BANNED',
`status_until` DATETIME(3) NULL COMMENT '临时封禁到期时间NULL 表示永久或未封禁',
`status_reason` VARCHAR(255) NULL COMMENT '封禁原因',
`token_version` BIGINT NOT NULL DEFAULT 1
COMMENT '凭据版本号,递增后所有已签发令牌立即失效',
`is_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否管理员',
-- 双因子认证
`totp_secret` VARCHAR(64) NULL COMMENT 'TOTP 密钥Base32NULL 表示未启用',
`totp_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'TOTP 是否已完成绑定',
-- 防爆破
`failed_attempts` INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
`locked_until` DATETIME(3) NULL COMMENT '锁定截止时间',
`last_login_at` DATETIME(3) NULL,
`last_login_ip` VARCHAR(64) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`user_id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB COMMENT='主控端用户账号';
-- =============================================================
-- 2. 密码历史(防止重复使用近期密码)
-- =============================================================
CREATE TABLE IF NOT EXISTS `password_history` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` VARCHAR(32) NOT NULL,
`password_hash` VARCHAR(100) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_user_created` (`user_id`, `created_at` DESC),
CONSTRAINT `fk_pwdhist_user` FOREIGN KEY (`user_id`)
REFERENCES `app_user` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='密码历史,建议保留最近 5 条';
-- =============================================================
-- 3. 登录会话(刷新令牌上下文)
-- =============================================================
CREATE TABLE IF NOT EXISTS `login_session` (
`session_id` VARCHAR(32) NOT NULL COMMENT '会话ID格式 ses_<20位Base62>',
`principal_id` VARCHAR(32) NOT NULL COMMENT '主体IDuser_id',
`principal_type` VARCHAR(16) NOT NULL DEFAULT 'USER' COMMENT 'USER/DEVICE',
`refresh_token_hash` VARCHAR(64) NOT NULL COMMENT '当前刷新令牌的 SHA-256轮转后更新',
`refresh_expires_at` DATETIME(3) NOT NULL,
`ip` VARCHAR(64) NULL,
`user_agent` VARCHAR(256) NULL,
`revoked` TINYINT(1) NOT NULL DEFAULT 0,
`revoked_reason` VARCHAR(255) NULL,
`last_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`session_id`),
KEY `idx_principal` (`principal_id`, `revoked`),
KEY `idx_expires` (`refresh_expires_at`)
) ENGINE=InnoDB COMMENT='登录会话,支持踢线与刷新令牌轮转';
-- =============================================================
-- 4. SN 白名单(出厂/部署时批量导入)
-- =============================================================
CREATE TABLE IF NOT EXISTS `allowed_device` (
`sn` VARCHAR(64) NOT NULL COMMENT '设备序列号',
`batch` VARCHAR(64) NULL COMMENT '批次标识',
`note` VARCHAR(255) NULL,
`imported_by` VARCHAR(64) NULL,
`imported_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`sn`),
KEY `idx_batch` (`batch`)
) ENGINE=InnoDB COMMENT='允许激活的 SN 白名单';
-- =============================================================
-- 5. 被控端设备
-- SN 仅作内部主键,对外一律使用高熵不可枚举的 device_uid
-- =============================================================
CREATE TABLE IF NOT EXISTS `device` (
`device_uid` VARCHAR(32) NOT NULL COMMENT '对外设备ID格式 dev_<22位Base62>',
`sn` VARCHAR(64) NOT NULL COMMENT '设备序列号(内部使用,勿对外暴露)',
`secret_hash` VARCHAR(64) NOT NULL COMMENT 'deviceSecret 的 SHA-256',
`model` VARCHAR(64) NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'
COMMENT 'ACTIVE/SUSPENDED/BANNED',
`status_until` DATETIME(3) NULL,
`status_reason` VARCHAR(255) NULL,
`token_version` BIGINT NOT NULL DEFAULT 1,
-- 防骚扰配置P1 阶段使用)
`strict_mode` TINYINT(1) NOT NULL DEFAULT 1
COMMENT '严格模式:仅允许已绑定账号连接',
`quiet_start` TIME NULL COMMENT '勿扰时段开始',
`quiet_end` TIME NULL COMMENT '勿扰时段结束',
`provisioned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`last_online_at` DATETIME(3) NULL,
PRIMARY KEY (`device_uid`),
UNIQUE KEY `uk_sn` (`sn`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB COMMENT='被控端设备身份';
-- =============================================================
-- 6. 设备激活 nonce防重放
-- 建议配合定时任务清理过期记录,或改用 Redis
-- =============================================================
CREATE TABLE IF NOT EXISTS `provision_nonce` (
`nonce` VARCHAR(64) NOT NULL,
`sn` VARCHAR(64) NULL,
`expires_at` DATETIME(3) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`nonce`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB COMMENT='激活请求 nonce用于防重放';
-- =============================================================
-- 7. 设备绑定关系P1 阶段:主控端唯一寻址凭证)
-- =============================================================
CREATE TABLE IF NOT EXISTS `device_binding` (
`binding_id` VARCHAR(36) NOT NULL COMMENT 'UUID',
`device_uid` VARCHAR(32) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`role` VARCHAR(16) NOT NULL DEFAULT 'OPERATOR'
COMMENT 'OWNER/OPERATOR/VIEWER',
`alias` VARCHAR(64) NULL COMMENT '主控端自定义备注名',
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/REVOKED',
`bound_by` VARCHAR(32) NULL,
`bound_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`expire_at` DATETIME(3) NULL COMMENT '绑定过期时间NULL 表示长期有效',
PRIMARY KEY (`binding_id`),
UNIQUE KEY `uk_device_user` (`device_uid`, `user_id`),
KEY `idx_user_status` (`user_id`, `status`),
KEY `idx_device_status` (`device_uid`, `status`)
) ENGINE=InnoDB COMMENT='设备与账号绑定关系';
-- =============================================================
-- 8. 配对码P1 阶段:建立绑定)
-- =============================================================
CREATE TABLE IF NOT EXISTS `pairing_code` (
`code_hash` VARCHAR(64) NOT NULL COMMENT '配对码的 SHA-256',
`device_uid` VARCHAR(32) NOT NULL,
`attempts` INT NOT NULL DEFAULT 0 COMMENT '错误尝试次数,超限即失效',
`used` TINYINT(1) NOT NULL DEFAULT 0,
`expires_at` DATETIME(3) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`code_hash`),
KEY `idx_device` (`device_uid`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB COMMENT='一次性配对码';
-- =============================================================
-- 9. 被控端黑名单P1 阶段:拉黑骚扰账号)
-- =============================================================
CREATE TABLE IF NOT EXISTS `device_blacklist` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`device_uid` VARCHAR(32) NOT NULL,
`blocked_user_id` VARCHAR(32) NOT NULL,
`reason` VARCHAR(255) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_device_user` (`device_uid`, `blocked_user_id`),
KEY `idx_device` (`device_uid`)
) ENGINE=InnoDB COMMENT='被控端黑名单';
-- =============================================================
-- 10. 连接会话记录WebRTC 通话审计)
-- =============================================================
CREATE TABLE IF NOT EXISTS `connection_session` (
`session_id` VARCHAR(36) NOT NULL,
`binding_id` VARCHAR(36) NULL,
`device_uid` VARCHAR(32) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`auth_type` VARCHAR(16) NULL COMMENT 'NONE/CODE/PASSWORD',
`started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`ended_at` DATETIME(3) NULL,
`end_reason` VARCHAR(64) NULL,
PRIMARY KEY (`session_id`),
KEY `idx_device_started` (`device_uid`, `started_at` DESC),
KEY `idx_user_started` (`user_id`, `started_at` DESC)
) ENGINE=InnoDB COMMENT='远程控制会话记录';
-- =============================================================
-- 11. 审计日志(只追加,不修改)
-- =============================================================
CREATE TABLE IF NOT EXISTS `audit_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`actor_type` VARCHAR(16) NOT NULL COMMENT 'USER/DEVICE/ADMIN/SYSTEM',
`actor_id` VARCHAR(64) NULL,
`action` VARCHAR(64) NOT NULL
COMMENT 'LOGIN/LOGIN_FAILED/LOGOUT/REGISTER/PROVISION/BAN/UNBAN/KICK/BIND/UNBIND/BLACKLIST...',
`target_type` VARCHAR(16) NULL,
`target_id` VARCHAR(64) NULL,
`result` VARCHAR(16) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAILURE',
`ip` VARCHAR(64) NULL,
`user_agent` VARCHAR(256) NULL,
`detail` VARCHAR(512) NULL COMMENT '注意:敏感信息需脱敏后再写入',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_actor` (`actor_id`, `created_at` DESC),
KEY `idx_action` (`action`, `created_at` DESC),
KEY `idx_created` (`created_at` DESC)
) ENGINE=InnoDB COMMENT='安全审计日志';
-- =============================================================
-- 12. 骚扰举报P2 阶段风控)
-- =============================================================
CREATE TABLE IF NOT EXISTS `abuse_report` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`device_uid` VARCHAR(32) NOT NULL COMMENT '举报方(被控端)',
`reported_user_id` VARCHAR(32) NOT NULL COMMENT '被举报账号',
`reason` VARCHAR(255) NULL,
`handled` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_reported` (`reported_user_id`, `created_at` DESC),
KEY `idx_handled` (`handled`)
) ENGINE=InnoDB COMMENT='骚扰举报记录';
-- =============================================================
-- 清理建议(可配置为 MySQL Event 或应用层定时任务)
-- =============================================================
-- DELETE FROM provision_nonce WHERE expires_at < NOW();
-- DELETE FROM pairing_code WHERE expires_at < NOW();
-- DELETE FROM login_session WHERE refresh_expires_at < NOW() OR revoked = 1;
-- DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY);
-- =============================================================
-- 初始管理员账号
-- =============================================================
-- 不在此处插入明文密码。请通过环境变量启动服务自动创建:
-- BOOTSTRAP_ADMIN_USERNAME=admin
-- BOOTSTRAP_ADMIN_PASSWORD=<强密码>
-- 或注册后手动提升权限:
-- UPDATE app_user SET is_admin = 1 WHERE username = 'admin';