import AuthenticationServices 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? // 空初值:登录页以 broker 账号登录为主,无操作时不显状态文案;各登录流程开始时自行设置。 var statusText: String = "" // 二维码已失效(denied/expired/失败)→ 登录页据此提示并高亮「刷新二维码」。 var qrExpired = false // 登录代次:每次 startQRLogin / startBrokerLogin 自增,旧流程据此识别自己已被取代、丢弃 // 结果,避免两条登录流程并发改 statusText / session 打架。 private var loginGeneration = 0 // Broker 登录期间强持 ASWebAuthenticationSession 封装(其 presentationContextProvider 为 weak、 // session 自身亦须保活至回调),完成 / 失败后置空。 private var brokerFlow: BrokerAuthFlow? // 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 } } // 应用内 Broker 登录(device-authorization + PKCE,见 BrokerLogin.swift):手机直接输账号登录, // 无需另一台设备扫码。流程=拉 broker 配置 → ASWebAuthenticationSession 授权拿 code → 换 bootstrap // 令牌 → 代铸成 cdrop 设备会话(带真名 / 稳定 device_id)→ 落 full 会话 + Keychain。 func startBrokerLogin() async { loginGeneration += 1 let gen = loginGeneration qrExpired = false statusText = t("ios.login.brokerStarting") do { let cfg = try await fetchAuthConfig() guard !cfg.brokerURL.isEmpty, !cfg.brokerApp.isEmpty else { throw BrokerLoginError.incompleteConfig } let verifier = PKCE.randomToken(64) let challenge = PKCE.challenge(for: verifier) let state = PKCE.randomToken(24) let redirectURI = "cdrop://auth-callback" var comp = URLComponents(string: cfg.brokerURL.trimmingTrailingSlash() + "/device/authorize")! comp.queryItems = [ URLQueryItem(name: "app", value: cfg.brokerApp), URLQueryItem(name: "redirect_uri", value: redirectURI), URLQueryItem(name: "state", value: state), URLQueryItem(name: "code_challenge", value: challenge), URLQueryItem(name: "code_challenge_method", value: "S256"), URLQueryItem(name: "description", value: "Commilitia Drop iOS"), ] guard let authURL = comp.url else { throw BrokerLoginError.incompleteConfig } let flow = BrokerAuthFlow() brokerFlow = flow let callback = try await flow.run(url: authURL, callbackScheme: "cdrop") brokerFlow = nil if gen != loginGeneration { return } let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] guard items.first(where: { $0.name == "state" })?.value == state else { throw BrokerLoginError.stateMismatch } guard let code = items.first(where: { $0.name == "code" })?.value, !code.isEmpty else { throw BrokerLoginError.missingCode } let bootstrap = try await exchangeDeviceToken( brokerURL: cfg.brokerURL, code: code, verifier: verifier, redirectURI: redirectURI) let ds = try await mintDeviceSession(bootstrap: bootstrap) if gen != loginGeneration { return } DeviceIDStore.value = ds.device_id let realName = (ds.name?.isEmpty == false) ? ds.name! : ds.user_id session = Session(accessToken: ds.access_token, refreshToken: ds.refresh_token, user: User(id: ds.user_id, name: realName, avatar: ds.avatar), deviceName: ds.device_name ?? DeviceNameStore.value, deviceId: ds.device_id, scope: "full") persist() } catch { brokerFlow = nil if gen != loginGeneration { return } // 用户主动取消(关闭授权页)不算失败:静默回到登录页等待再次操作。 if let asErr = error as? ASWebAuthenticationSessionError, asErr.code == .canceledLogin { statusText = t("ios.login.waiting") return } statusText = t("ios.login.failed") } } func logout() { session = nil qrPayload = nil statusText = "" Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) } // 静态清除持久会话:被其他设备登出的后台推送到达时(AppDelegate)无 live AuthManager 实例可调 // logout(),故提供静态版只清 Keychain——下次启动 init 读不到会话即为登出态。前台另经 // .cdropSessionRevoked 通知走实例 logout() 即时回登录页。 static func clearPersistedSession() { Keychain.delete(service: keychainService, account: keychainAccount) } // ---- Broker 登录辅助:配置拉取 / 令牌交换 / 代铸 ---- private struct AuthConfig { let brokerURL: String; let brokerApp: String } private struct DeviceTokenResp: Decodable { let access: String let refresh: String let access_expires: Int64 } // 代铸响应:access/refresh + 稳定 device_id + 经边缘核验的真实身份(user_id/name/avatar)。 private struct DeviceSessionResp: Decodable { let access_token: String let refresh_token: String let device_id: String let device_name: String? let user_id: String let name: String? let avatar: String? } private func fetchAuthConfig() async throws -> AuthConfig { let (data, _) = try await URLSession.shared.data(from: URL(string: "\(apiBase)/api/auth/config")!) let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] return AuthConfig(brokerURL: obj["broker_url"] as? String ?? "", brokerApp: obj["broker_app"] as? String ?? "") } private func exchangeDeviceToken( brokerURL: String, code: String, verifier: String, redirectURI: String) async throws -> String { var req = URLRequest(url: URL(string: brokerURL.trimmingTrailingSlash() + "/device/token")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Accept") req.httpBody = try JSONSerialization.data(withJSONObject: [ "code": code, "code_verifier": verifier, "redirect_uri": redirectURI, ]) let (data, resp) = try await URLSession.shared.data(for: req) let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 guard status == 200 else { throw BrokerLoginError.tokenFailed(status) } let tr = try JSONDecoder().decode(DeviceTokenResp.self, from: data) guard !tr.access.isEmpty else { throw BrokerLoginError.badResponse } return tr.access } private func mintDeviceSession(bootstrap: String) async throws -> DeviceSessionResp { var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/device-session")!) req.httpMethod = "POST" req.setValue("Bearer \(bootstrap)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("ios", forHTTPHeaderField: "X-Device-Type") req.httpBody = try JSONSerialization.data(withJSONObject: [ "device_id": DeviceIDStore.value, "device_name": DeviceNameStore.value, "device_type": "ios", ]) let (data, resp) = try await URLSession.shared.data(for: req) let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 guard status == 200 else { throw BrokerLoginError.deviceSessionFailed(status) } return try JSONDecoder().decode(DeviceSessionResp.self, from: data) } // 持久化当前会话到 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() } // updateIdentity:引擎据 /api/me 取到真实显示名(QR 登录注入的是 subject UUID 名)→ 经桥 // identityUpdated 回报,更新会话身份 + Keychain,使设置页显示真名而非 UUID(#12)。仅换 // 显示身份,令牌 / 设备名不变。 func updateIdentity(name: String, avatar: String?) { guard let cur = session else { return } let av = (avatar?.isEmpty == false) ? avatar : cur.user.avatar session = Session(accessToken: cur.accessToken, refreshToken: cur.refreshToken, user: User(id: cur.user.id, name: name, avatar: av), deviceName: cur.deviceName, deviceId: cur.deviceId, scope: cur.scope) persist() } // updateDeviceName:本机即时改名成功(PATCH /api/devices/{device_id})后经桥 renamed 回报, // 更新会话里的设备名 + Keychain,使重启后 boot 注入新名。令牌 / 身份不变——设备名与令牌解耦。 func updateDeviceName(_ name: String) { guard let cur = session else { return } session = Session(accessToken: cur.accessToken, refreshToken: cur.refreshToken, user: cur.user, deviceName: name, 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 } }