feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -72,7 +72,18 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
/// 最近一次录制保存的文件路径(沙盒内)
|
||||
@Published var lastRecordingPath: String?
|
||||
|
||||
let myDeviceId: String = DeviceUtils.deviceId()
|
||||
/// 本机设备ID:初始为空,连接后由服务端 REGISTER_SUCCESS 下发(fromDeviceId)。
|
||||
@Published var myDeviceId: String = ""
|
||||
|
||||
// MARK: - 登录态
|
||||
|
||||
@Published var isLoggedIn: Bool = false
|
||||
@Published var username: String = ""
|
||||
@Published var loginError: String?
|
||||
@Published var isLoggingIn: Bool = false
|
||||
|
||||
/// 当前 accessToken(Bearer 握手用),内存持有;refreshToken 存 Keychain。
|
||||
private var accessToken: String?
|
||||
|
||||
// MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入)
|
||||
|
||||
@@ -102,6 +113,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
override init() {
|
||||
super.init()
|
||||
remoteVideoView.delegate = self
|
||||
restoreLoginState()
|
||||
selfCodecDecoder.displayLayer = selfCodecView.sampleBufferLayer
|
||||
selfCodecDecoder.onResolutionUpdate = { [weak self] w, h in
|
||||
guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return }
|
||||
@@ -129,7 +141,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - 连接 / 断开
|
||||
|
||||
/// 用户点击"连接":弹出鉴权选择
|
||||
/// 用户点击"连接":先确保已登录(Bearer token),再弹出鉴权选择
|
||||
func requestConnect() {
|
||||
let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||
@@ -140,10 +152,14 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
alertMessage = "请输入目标设备 ID"
|
||||
return
|
||||
}
|
||||
guard isLoggedIn else {
|
||||
alertMessage = "请先在右上角登录账号"
|
||||
return
|
||||
}
|
||||
showAuthSheet = true
|
||||
}
|
||||
|
||||
/// 鉴权弹窗确认后开始连接
|
||||
/// 鉴权弹窗确认后开始连接(已确保 accessToken 有效)。
|
||||
func connect(authType: AuthType, authValue: String) {
|
||||
showAuthSheet = false
|
||||
pendingAuthType = authType
|
||||
@@ -153,7 +169,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
let signaling = SignalingClient(
|
||||
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces),
|
||||
deviceId: myDeviceId)
|
||||
token: accessToken)
|
||||
signaling.delegate = self
|
||||
signalingClient = signaling
|
||||
signaling.connect()
|
||||
@@ -277,6 +293,10 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
fileprivate func startWebRTC() {
|
||||
guard let signaling = signalingClient else { return }
|
||||
guard !myDeviceId.isEmpty else {
|
||||
failConnection("未获取到本机设备ID,连接中止")
|
||||
return
|
||||
}
|
||||
statusText = "正在建立 WebRTC 连接..."
|
||||
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
|
||||
client.delegate = self
|
||||
@@ -295,6 +315,141 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
// MARK: - 登录 / 令牌
|
||||
|
||||
/// 应用启动时从 Keychain 恢复登录态(accessToken / refreshToken)。
|
||||
private func restoreLoginState() {
|
||||
let at = TokenStore.loadAccessToken()
|
||||
let rt = TokenStore.loadRefreshToken()
|
||||
if at != nil, rt != nil {
|
||||
accessToken = at
|
||||
username = TokenStore.loadUsername() ?? ""
|
||||
isLoggedIn = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录:调用 /api/auth/login,保存令牌并恢复登录态。
|
||||
func login(username: String, password: String) async {
|
||||
isLoggingIn = true
|
||||
loginError = nil
|
||||
do {
|
||||
let data = try await ApiClient.login(username: username, password: password)
|
||||
guard let at = data["accessToken"] as? String,
|
||||
let rt = data["refreshToken"] as? String else {
|
||||
throw ApiError(message: "登录返回缺失")
|
||||
}
|
||||
TokenStore.saveAccessToken(at)
|
||||
TokenStore.saveRefreshToken(rt)
|
||||
TokenStore.saveUsername(username)
|
||||
accessToken = at
|
||||
self.username = username
|
||||
isLoggedIn = true
|
||||
} catch {
|
||||
loginError = (error as? ApiError)?.message ?? error.localizedDescription
|
||||
}
|
||||
isLoggingIn = false
|
||||
}
|
||||
|
||||
/// 退出登录:清空令牌与状态。
|
||||
func logout() {
|
||||
TokenStore.clear()
|
||||
accessToken = nil
|
||||
username = ""
|
||||
isLoggedIn = false
|
||||
if isConnecting || isControlling {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
|
||||
private func ensureAccessToken() async throws -> String {
|
||||
if let existing = accessToken {
|
||||
do {
|
||||
_ = try await ApiClient.verify(accessToken: existing)
|
||||
return existing
|
||||
} catch {
|
||||
// 校验失败可能是过期,走刷新。
|
||||
}
|
||||
}
|
||||
guard let rt = TokenStore.loadRefreshToken() else {
|
||||
throw ApiError(message: "无 refreshToken,请重新登录")
|
||||
}
|
||||
let data = try await ApiClient.refresh(refreshToken: rt)
|
||||
guard let newAt = data["accessToken"] as? String else {
|
||||
throw ApiError(message: "刷新失败")
|
||||
}
|
||||
TokenStore.saveAccessToken(newAt)
|
||||
if let newRt = data["refreshToken"] as? String {
|
||||
TokenStore.saveRefreshToken(newRt)
|
||||
}
|
||||
accessToken = newAt
|
||||
return newAt
|
||||
}
|
||||
|
||||
/// 令牌失效后刷新并重连。
|
||||
private func refreshAndReconnect() async {
|
||||
do {
|
||||
let newAt = try await ensureAccessToken()
|
||||
accessToken = newAt
|
||||
DispatchQueue.main.async {
|
||||
self.signalingClient?.disconnect()
|
||||
self.signalingClient = nil
|
||||
// 用新令牌重建连接(REGISTER_SUCCESS 会再次触发 startWebRTC)。
|
||||
let signaling = SignalingClient(
|
||||
serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces),
|
||||
token: newAt)
|
||||
signaling.delegate = self
|
||||
self.signalingClient = signaling
|
||||
signaling.connect()
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.logout()
|
||||
self.alertMessage = "令牌刷新失败,请重新登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备),用于提示。
|
||||
private func loadBindings() {
|
||||
guard let at = accessToken else { return }
|
||||
Task {
|
||||
do {
|
||||
let data = try await ApiClient.bindings(accessToken: at)
|
||||
guard let list = data["bindings"] as? [[String: Any]], !list.isEmpty else { return }
|
||||
let uids = list.compactMap { $ -> String? in
|
||||
if let u = $["deviceUid"] as? String, !u.isEmpty { return u }
|
||||
return $["deviceId"] as? String
|
||||
}.filter { !$0.isEmpty }
|
||||
if !uids.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.alertMessage = "已绑定设备: " + uids.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取 TURN 短期凭证,覆盖默认 ICE(由 WebRTCClient 读取)。
|
||||
private func loadTurnCredentials() async {
|
||||
guard let at = accessToken else { return }
|
||||
do {
|
||||
let data = try await ApiClient.turnCredentials(accessToken: at)
|
||||
guard let servers = data["iceServers"] as? [[String: Any]], !servers.isEmpty else { return }
|
||||
var mapped: [[String: Any]] = []
|
||||
for s in servers {
|
||||
var entry: [String: Any] = [:]
|
||||
if let urls = s["urls"] as? String { entry["urls"] = urls }
|
||||
if let user = s["username"] as? String { entry["username"] = user }
|
||||
if let cred = s["credential"] as? String { entry["credential"] = cred }
|
||||
mapped.append(entry)
|
||||
}
|
||||
if !mapped.isEmpty {
|
||||
WebRTCClient.iceServersOverride = mapped
|
||||
}
|
||||
} catch { /* 服务端未开启 TURN,忽略 */ }
|
||||
}
|
||||
|
||||
// MARK: - 统计
|
||||
|
||||
private func startStatsTimer() {
|
||||
@@ -431,8 +586,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
extension ControllerViewModel: SignalingClientDelegate {
|
||||
|
||||
func signalingDidConnect() {
|
||||
statusText = "信令已连接,正在发起会话..."
|
||||
startWebRTC()
|
||||
statusText = "信令已连接,等待注册..."
|
||||
}
|
||||
|
||||
func signalingDidDisconnect() {
|
||||
@@ -445,7 +599,30 @@ extension ControllerViewModel: SignalingClientDelegate {
|
||||
failConnection("信令连接失败: \(error)")
|
||||
}
|
||||
|
||||
/// 令牌失效(关闭码 4001):刷新令牌后重连。
|
||||
func signalingTokenExpired() {
|
||||
Task { await refreshAndReconnect() }
|
||||
}
|
||||
|
||||
/// 强制下线(关闭码 4003):清空登录态,跳回登录。
|
||||
func signalingForceLogout() {
|
||||
DispatchQueue.main.async {
|
||||
self.logout()
|
||||
self.alertMessage = "账号已在其他位置登录,已强制下线"
|
||||
}
|
||||
}
|
||||
|
||||
func signaling(didReceive message: SignalMessage) {
|
||||
// 服务端下发本机 deviceId(CONTROLLER),随后发起 WebRTC Offer。
|
||||
if (message.type ?? "").uppercased() == "REGISTER_SUCCESS",
|
||||
let from = message.fromDeviceId, !from.isEmpty {
|
||||
myDeviceId = from
|
||||
statusText = "已注册 (\(from)),正在发起会话..."
|
||||
startWebRTC()
|
||||
loadBindings()
|
||||
Task { await loadTurnCredentials() }
|
||||
return
|
||||
}
|
||||
switch (message.type ?? "").uppercased() {
|
||||
case "ANSWER":
|
||||
if let sdp = message.payloadJSON()?["sdp"] as? String {
|
||||
|
||||
Reference in New Issue
Block a user