Files
Commilitia-Drop/ios/CDrop/Sources/Auth/AuthManager.swift
T
admin a2cad11224 后台/会话三诉求:子会话(一台设备一会话)+ 离线消息队列 + 被登出推送 + 后台唤醒层
- Req 1 子会话(iOS 多进程在用户视角=一台设备一会话,内部多条独立刷新逻辑会话):brokerclient 加 MintParams.Sub + SessionInfo.Sub + RevokeDeviceSessions(user,meta) 级联;device-session sub!="" 挂在主 device_id 之下、走 tier=clipboard 且不建第二个 device 行;handleSessionsList 按 meta 归并、隐藏 sub!=""(但保留“仅控件会话存活”的设备,不简单丢弃);revokeDevice 改走 meta 级联(移除设备连带吊控件子会话)。iOS provisionWidgetSessionIfNeeded 改用主 device_id + sub="widget"(弃用独立 dev_widget),控件令牌仍隔离持有、独立刷新——不碰引擎主会话,#7 隔离不变。蓝本 auth/docs/子会话方案.md(cdrop 提案 + Broker 评审接受 + cdrop 确认 6 点:用 sub、R1 cdrop 归并、scope 复用 tier=clipboard、级联 DELETE …?meta=)。
- Req 2a 离线消息队列:新增 pending_messages 表(0001_init + sqlc 查询);message.go 收件设备离线即入队(无论是否配推送都入队,恒 202),修“离线消息只随推送横幅一闪、不入收件列表”;GET /api/messages/pending 取即删(DELETE..RETURNING,单次投递);web hub.ts onOpen 每次 SSE 连接 / 重连即拉取补收、逐条 addMessage(全端受益,iOS 经桥推原生 + 累积未读);复用 login-request reaper 清 TTL。
- Req 3 被登出推送:push 加 KindSessionRevoked + 本地化文案,apns/web 双通道;revokeDevice 加 notifyRevoked 参(跨端 revoke=true、自登出=false),跨端移除时推送告知被踢设备;iOS AppDelegate 收 session:revoked 即清 Keychain 主会话 + 控件会话、发 .cdropSessionRevoked,前台经 AppRoot 即时回登录页、关闭态下次启动即登出。
- #6 后台唤醒层:apns apsEnvelope 加 content-available:1(服务端有消息时既弹可点横幅又短暂后台唤醒);iOS DeviceItem 加 Codable、presence 快照持久化(冷启即时显示设备列表,缓解“长时间重连”观感,SSE 一连即整组替换自校正);控件会话回前台补铸(scenePhase active)+ content-available 唤醒时刷新隔离的控件会话(剪贴板保活,绝不碰引擎主会话以免 #7 回归)。
- 测试:qr_test mock broker 加 sub 幂等键 + 级联吊销端点;bootstrap_test 期望表集加 pending_messages。
2026-06-27 17:42:53 +08:00

400 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AuthenticationServices
import Foundation
import Observation
import UIKit
// + iOS web /
// /api/auth/qr/status / internal/httpapi/
// qr.go REST WebView engine.html
//
// Auth Broker Aaccess_token 900sWebView refresh_token
// POST /api/auth/refresh {refresh_token} cdrop broker refresh.gobroker
// refresh sessionRotated Keychainrefresh / 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
// refreshTokenbroker access
let refreshToken: String
let user: User
let deviceName: String
// deviceIdcdrop 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()
}
// updateDeviceNamePATCH /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
}
}