Files
Commilitia-Drop/ios/CDrop/Sources/Auth/AuthManager.swift
T

402 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
#if canImport(UIKit)
import UIKit
#endif
// + 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 = "commilitia-drop://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"),
]
guard let authURL = comp.url else { throw BrokerLoginError.incompleteConfig }
let flow = BrokerAuthFlow()
brokerFlow = flow
let callback = try await flow.run(url: authURL, callbackScheme: "commilitia-drop")
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
}
}