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

22 KiB
Raw Blame History

客户端对接接口规范 v1

面向 5 个客户端:WebRTCControlled(被控端)、WebRTCController(主控端 AndroidWebRTCControllerWebwebrtc_controller_flutterwebrtc_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 OKJSON body。

失败:

{ "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

{ "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

{
  "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 的账号:若响应为 401errorTOTP_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>
{
  "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>
[
  {
    "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>
{ "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

{ "username": "alice", "password": "Passw0rd!", "totpCode": "123456" }

1.9 令牌校验(建立长连前的预检)

客户端在发起 WebSocket 握手前,可先调用此接口确认令牌未过期 / 未失效, 避免握手阶段被服务端直接断开(关闭码 4001。用户令牌与设备令牌均可使用。

GET /api/client/verify
Authorization: Bearer <token>

有效时响应 200

{
  "valid": true,
  "principalType": "DEVICE",          // USER 或 DEVICE
  "principalId": "dev_xxx",
  "displayName": "Pixel 3",
  "expiresAt": 1730000900,            // 令牌过期时间(秒)
  "remainingSeconds": 812,            // 剩余有效秒数
  "serverTime": 1730000088
}

无效时响应 200HTTP 仍为 200业务字段辨状态避免泄露令牌是否存在

{ "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>
{
  "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
{
  "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
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"},成功返回绑定信息
# 被控端生成
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-infoturnEnabled 字段告知客户端。

方法 路径 令牌 说明
GET /api/client/turn-credentials 用户/设备 返回 iceServers(含 urls/username/credentialexpiresAtttlSeconds
{
  "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 ) )
  • timestampUnix (非毫秒),与服务端偏差超过 300 秒即拒绝
  • nonce:每次请求唯一(建议 UUID服务端做重放检测
  • DEVICE_PROVISION_SECRET:内置于被控端 APK建议放 NDK 层并配合 R8 混淆

响应 200

{
  "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..." }
{
  "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 — 两端通用:

Request request = new Request.Builder()
        .url(serverUrl)
        .addHeader("Authorization", "Bearer " + accessToken)
        .build();
client.newWebSocket(request, listener);

Web浏览器 WebSocket — 浏览器无法设置请求头,必须用子协议

const ws = new WebSocket(serverUrl, ['signal.v1', 'auth.' + accessToken]);

Flutterweb_socket_channel

// IOWebSocketChannel 支持 headers移动端
final channel = IOWebSocketChannel.connect(
  Uri.parse(serverUrl),
  headers: {'Authorization': 'Bearer $accessToken'},
);
// 若需兼容 Flutter Web改用子协议
// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.$accessToken']);

iOSURLSessionWebSocketTask

var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let task = session.webSocketTask(with: request)

3.3 连接建立后

不再需要发送 REGISTER。认证通过后服务端自动完成注册并主动下发:

{
  "type": "REGISTER_SUCCESS",
  "deviceId": "ctl_ses_xxx",
  "deviceType": "CONTROLLER",
  "displayName": "alice"
}

客户端应以此消息中的 deviceId 作为自己的信令 ID。

旧客户端继续发送 REGISTER 不会报错(服务端仅回显同样的响应),但已无实际意义。

3.4 身份字段不再可信

消息中的 fromDeviceIddeviceType 会被服务端用鉴权结果强制覆盖。 客户端填写任意值都无效(不一致时服务端记录 WARN 日志)。发送消息时可继续填写,也可省略。

3.5 关闭码处理

关闭码 含义 客户端应对
4001 未认证 / 令牌无效 刷新令牌后重连;刷新失败则跳登录
4003 被强制下线(封禁/踢出/设备禁用) 停止自动重连,提示用户,清空令牌跳登录
1000/1001 正常关闭 按既有逻辑
其他 网络异常 指数退避重连

特别注意:被控端 WebRTCControlled 现有的自动重连是指数退避2s 起,上限 30s。 收到 4003 时必须关闭自动重连,否则会造成无效重连风暴。

3.6 FORCE_LOGOUT 消息

服务端在强制断开前会先下发:

{ "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

说明
新增 登录视图 + AuthServiceURLSession HTTP
新增 Keychain 存 refreshToken当前 DeviceUtils 用的是 UserDefaults不适合存凭据
修改 SignalingClient.swiftURLRequest + Authorization
修改 移除 DeviceUtils.deviceId() 作为信令 ID 的逻辑
修改 处理关闭码与 FORCE_LOGOUT

5. 联调顺序建议

  1. 先确认反向代理已放行 /api/**
  2. 服务端配置 JWT_SECRETDEVICE_PROVISION_SECRETBOOTSTRAP_ADMIN_PASSWORD
  3. 用 curl 验证 login / provision 流程跑通
  4. 改造被控端(无 UI 依赖,最容易验证)
  5. 改造Web 端(调试最快,可验证子协议方式)
  6. 改造 Android 主控端 → Flutter → iOS

curl 自测示例

# 注册
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":"..."}'