feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
/// 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。
|
||||
///
|
||||
/// - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀);
|
||||
/// - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken);
|
||||
/// - verify() → 校验 accessToken 是否仍有效;
|
||||
/// - bindings() → 本机可连接的被控端列表(仅已绑定设备);
|
||||
/// - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。
|
||||
struct ApiClient {
|
||||
|
||||
/// 服务端 HTTP 基址(与信令同源)。部署时通过 Build Setting / Info.plist 注入。
|
||||
static var baseURL: String {
|
||||
// 优先读取 Info.plist 中的 APIBase,缺省回退到信号服务器同源 HTTPS。
|
||||
if let v = Bundle.main.object(forInfoDictionaryKey: "APIBase") as? String, !v.isEmpty {
|
||||
return v
|
||||
}
|
||||
return "https://www.ttstd.com"
|
||||
}
|
||||
|
||||
/// 登录:成功返回 accessToken / refreshToken / expireAt。
|
||||
static func login(username: String, password: String) async throws -> [String: Any] {
|
||||
let body: [String: Any] = ["username": username, "password": password]
|
||||
return try await post("/api/auth/login", body: body)
|
||||
}
|
||||
|
||||
/// 刷新令牌:body 需带 refreshToken;成功返回新的 accessToken(可能轮换 refreshToken)。
|
||||
static func refresh(refreshToken: String) async throws -> [String: Any] {
|
||||
let body: [String: Any] = ["refreshToken": refreshToken]
|
||||
return try await post("/api/auth/refresh", body: body)
|
||||
}
|
||||
|
||||
/// 校验当前 accessToken 是否有效。
|
||||
static func verify(accessToken: String) async throws -> [String: Any] {
|
||||
return try await get("/api/client/verify", accessToken: accessToken)
|
||||
}
|
||||
|
||||
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备)。
|
||||
static func bindings(accessToken: String) async throws -> [String: Any] {
|
||||
return try await get("/api/client/bindings", accessToken: accessToken)
|
||||
}
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端未开启时抛错,调用方忽略)。
|
||||
static func turnCredentials(accessToken: String) async throws -> [String: Any] {
|
||||
return try await get("/api/client/turn-credentials", accessToken: accessToken)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {
|
||||
var req = URLRequest(url: URL(string: baseURL + path)!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
private static func get(_ path: String, accessToken: String) async throws -> [String: Any] {
|
||||
var req = URLRequest(url: URL(string: baseURL + path)!)
|
||||
req.httpMethod = "GET"
|
||||
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
private static func perform(_ request: URLRequest) async throws -> [String: Any] {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ApiError(message: "无效响应")
|
||||
}
|
||||
if http.statusCode == 401 {
|
||||
throw ApiError(code: "UNAUTHORIZED", message: "令牌失效", httpCode: 401)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String
|
||||
?? "HTTP \(http.statusCode)"
|
||||
throw ApiError(message: msg)
|
||||
}
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
struct ApiError: Error {
|
||||
let code: String
|
||||
let message: String
|
||||
let httpCode: Int
|
||||
|
||||
init(code: String = "ERROR", message: String, httpCode: Int = -1) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.httpCode = httpCode
|
||||
}
|
||||
|
||||
var localizedDescription: String { message }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// 主控端令牌安全存储(Keychain)。
|
||||
///
|
||||
/// refreshToken 为一次性(ses_ 前缀),安全要求高,存 Keychain;
|
||||
/// accessToken 仅内存持有(掉线即失),这里不持久化。
|
||||
enum TokenStore {
|
||||
|
||||
private static let service = "com.ttstd.webrtccontroller"
|
||||
private static let accessKey = "access_token"
|
||||
private static let refreshKey = "refresh_token"
|
||||
private static let userKey = "username"
|
||||
|
||||
// MARK: - Access Token(内存 + Keychain 双重保留,便于冷启动恢复)
|
||||
|
||||
static func saveAccessToken(_ token: String) {
|
||||
save(key: accessKey, value: token)
|
||||
}
|
||||
|
||||
static func loadAccessToken() -> String? {
|
||||
load(key: accessKey)
|
||||
}
|
||||
|
||||
// MARK: - Refresh Token(一次性,必须持久化于 Keychain)
|
||||
|
||||
static func saveRefreshToken(_ token: String) {
|
||||
save(key: refreshKey, value: token)
|
||||
}
|
||||
|
||||
static func loadRefreshToken() -> String? {
|
||||
load(key: refreshKey)
|
||||
}
|
||||
|
||||
static func saveUsername(_ name: String) {
|
||||
save(key: userKey, value: name)
|
||||
}
|
||||
|
||||
static func loadUsername() -> String? {
|
||||
load(key: userKey)
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
for k in [accessKey, refreshKey, userKey] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: k
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func save(key: String, value: String) {
|
||||
// 先删后存,避免重复条目。
|
||||
let query: [String: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecValueData: Data(value.utf8)
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private static func load(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user