import Foundation import Observation import UIKit // 扫码登录(新设备侧)+ 会话持有。iOS 显示二维码,已登录的 web / 桌面设备用“扫码登录” // 扫码批准,本端轮询 /api/auth/qr/status 拿到自签会话。字段 / 流程见后端 internal/httpapi/ // qr.go。登录是纯 REST(不经引擎 WebView),故 engine.html 未部署也能登录。 // // 续期(Auth Broker 路径 A):access_token 默认 900s;引擎(WebView)持注入的 refresh_token, // 到期经 POST /api/auth/refresh {refresh_token} 自刷(cdrop 代理 broker,见 refresh.go),broker // 轮换 refresh 后经桥 sessionRotated 回报本端更新 Keychain;refresh 过期 / 吊销则经 authExpired // 回登录页重扫。本端不再依赖 cdrop_session cookie。 @MainActor @Observable final class AuthManager { struct User: Codable { let id: String let name: String let avatar: String? } struct Session: Codable { let accessToken: String // refreshToken:broker 委托会话的续期凭证,随 access 一并注入引擎自刷用;轮换后更新。 let refreshToken: String let user: User let deviceName: String // deviceId:cdrop 生成的稳定不透明设备 id(= broker meta,回显 X-Auth-Meta)。 let deviceId: String let scope: String } var session: Session? var qrPayload: String? var statusText: String = t("ios.login.generating") // 二维码已失效(denied/expired/失败)→ 登录页据此提示并高亮「刷新二维码」。 var qrExpired = false // 登录代次:每次 startQRLogin 自增,旧轮询据此识别自己已被刷新取代、丢弃结果,避免 // 「刷新时仍在轮询」两条循环并发改 statusText / qrPayload 打架。 private var loginGeneration = 0 // Keychain 持久化坐标(单 app 私有,见 Keychain.swift)。 private static let keychainService = "net.commilitia.cdrop.session" private static let keychainAccount = "session" // 启动即尝试恢复持久会话:命中则 app 直接进主界面,免反复扫码(迭代 / 重装友好)。 // 恢复的令牌可能已过期(guest 1h 上限)——此时引擎鉴权 401,用户经设置页登出重扫即可。 init() { if let data = Keychain.load(service: Self.keychainService, account: Self.keychainAccount), let restored = try? JSONDecoder().decode(Session.self, from: data) { // 仅恢复完整权限会话;陈旧 guest(旧版本落地的)丢弃、强制重登为 full。 if restored.scope == "full" { session = restored } else { Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) } } } // 后端基址:默认线上,可经 CDROP_API_BASE 覆盖(本机测试)。 private var apiBase: String { ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "https://drop.commilitia.net" } private struct QRStart: Decodable { let request_id: String let poll_secret: String let qr_payload: String let expires_at: Int64 } private struct QRStatus: Decodable { let status: String let access_token: String? let refresh_token: String? let device_id: String? let expires_in: Int? let user: User? let device_name: String? } // startQRLogin:发起二维码 + 轮询直到批准 / 失效。可重复调用(刷新二维码):自增代次, // 旧轮询识别到代次变更即弃。 func startQRLogin() async { loginGeneration += 1 let gen = loginGeneration qrExpired = false qrPayload = nil statusText = t("ios.login.generating") do { let start = try await qrStart() if gen != loginGeneration { return } qrPayload = start.qr_payload statusText = t("ios.login.waiting") try await poll(requestID: start.request_id, pollSecret: start.poll_secret, gen: gen) } catch { if gen != loginGeneration { return } statusText = t("ios.login.failed") qrExpired = true } } func logout() { session = nil qrPayload = nil statusText = t("ios.login.generating") Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) } // 持久化当前会话到 Keychain(扫码批准后调用)。 private func persist() { guard let session, let data = try? JSONEncoder().encode(session) else { return } Keychain.save(data, service: Self.keychainService, account: Self.keychainAccount) } // updateSession:引擎自刷致 broker 轮换后,经桥 sessionRotated 回报的新令牌对 → 更新 // 内存会话 + Keychain,使重启后不再用已失效的旧 refresh。仅换令牌,身份 / 设备名不变。 func updateSession(accessToken: String, refreshToken: String) { guard let cur = session else { return } session = Session(accessToken: accessToken, refreshToken: refreshToken, user: cur.user, deviceName: cur.deviceName, deviceId: cur.deviceId, scope: cur.scope) persist() } // 仅供本机模拟器联调(CDROP_DEBUG_SESSION=1):注入占位会话跳过扫码,用于验证引擎从 // prod 加载 + 桥往返。令牌无效、鉴权会 401,但能证 engine.html 在 WKWebView 里加载运行。 func debugSession() { session = Session(accessToken: "debug", refreshToken: "", user: User(id: "debug", name: "Simulator", avatar: nil), deviceName: "Simulator", deviceId: "", scope: "guest") } private func qrStart() async throws -> QRStart { var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/qr/start")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("ios", forHTTPHeaderField: "X-Device-Type") req.httpBody = try JSONSerialization.data( withJSONObject: [ "device_name": deviceName(), "device_type": "ios" ]) let (data, _) = try await URLSession.shared.data(for: req) return try JSONDecoder().decode(QRStart.self, from: data) } private func poll(requestID: String, pollSecret: String, gen: Int) async throws { while session == nil && gen == loginGeneration { var comp = URLComponents(string: "\(apiBase)/api/auth/qr/status")! comp.queryItems = [ URLQueryItem(name: "request_id", value: requestID) ] var req = URLRequest(url: comp.url!) req.setValue(pollSecret, forHTTPHeaderField: "X-Poll-Secret") let (data, _) = try await URLSession.shared.data(for: req) if gen != loginGeneration { return } // 已被刷新取代,丢弃本轮结果 let st = try JSONDecoder().decode(QRStatus.self, from: data) switch st.status { case "approved": if let token = st.access_token, let user = st.user { // 原生 App 仅允许完整权限(用户原则:iOS/桌面只接受 full,受限访客是 Web/PWA // 的权宜)。批准方若按「仅此次」授予 guest,这里据 /api/me 的真实 scope 拒绝、 // 提示改选「信任此设备」,并让二维码可刷新重扫——不落地 guest 会话。 let scope = await fetchScope(token: token) if gen != loginGeneration { return } if scope != "full" { statusText = t("ios.login.needFull") qrExpired = true return } session = Session(accessToken: token, refreshToken: st.refresh_token ?? "", user: user, deviceName: st.device_name ?? deviceName(), deviceId: st.device_id ?? "", scope: "full") persist() } return case "denied", "expired": statusText = t("ios.login.expired") qrExpired = true return default: continue // pending → 立即下一轮(服务端长轮询最多挂 25s) } } } // 据刚发的 access token 拉 /api/me 取真实会话级别(full / guest)。失败回空串(按非 full // 处理,保守拒绝)。 private func fetchScope(token: String) async -> String { var req = URLRequest(url: URL(string: "\(apiBase)/api/me")!) req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") guard let (data, _) = try? await URLSession.shared.data(for: req), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let scope = obj["scope"] as? String else { return "" } return scope } private func deviceName() -> String { return DeviceNameStore.value } }