diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index acf01f66e8..d0b3a3067d 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -57,7 +57,11 @@ class AppManager: ObservableObject }() private weak var requestUDIDAction: UIAlertAction? - + + let authenticationCache = AuthenticationCache() + private weak var pendingAuthenticationOperation: AuthenticationOperation? + private let authenticationLock = NSLock() + private init() { self.operationQueue.name = "com.altstore.AppManager.operationQueue" @@ -348,11 +352,33 @@ extension AppManager completionHandler(result) } authenticationOperation.addDependency(prepareServerOperation) - + + // Wait for a sign-in already in progress so this operation can reuse its session. + self.authenticationLock.lock() + if let pendingOperation = self.pendingAuthenticationOperation, !pendingOperation.isFinished + { + authenticationOperation.addDependency(pendingOperation) + } + self.pendingAuthenticationOperation = authenticationOperation + self.authenticationLock.unlock() + self.run([authenticationOperation], context: context) - + return authenticationOperation } + + /// Drops the reused sign-in after Apple rejects a request made with it, including a + /// result code AltSign passes through unmapped and an HTML error page that fails to + /// parse as a plist. + func invalidateAuthenticationIfNeeded(for error: Error) + { + let nsError = error as NSError + let isAppleError = (nsError.domain == ALTAppleAPIErrorDomain || nsError.domain == ALTUnderlyingAppleAPIErrorDomain) + let isUnparsedAppleResponse = (nsError.domain == NSCocoaErrorDomain && nsError.code == NSPropertyListReadCorruptError) + guard isAppleError || isUnparsedAppleResponse else { return } + + self.authenticationCache.invalidate() + } @discardableResult func fetchPairingFile(context: OperationContext = OperationContext(), completionHandler: @escaping (Result) -> Void) -> FetchPairingFileOperation @@ -734,7 +760,14 @@ extension AppManager } let fetchAppIDsOperation = FetchAppIDsOperation(context: authenticationOperation.context) - fetchAppIDsOperation.resultHandler = completionHandler + fetchAppIDsOperation.resultHandler = { (result) in + if case .failure(let error) = result + { + self.invalidateAuthenticationIfNeeded(for: error) + } + + completionHandler(result) + } fetchAppIDsOperation.addDependency(authenticationOperation) self.run([fetchAppIDsOperation], context: authenticationOperation.context) } @@ -2254,7 +2287,8 @@ private extension AppManager let error = nsError.withLocalizedTitle(localizedTitle) group.set(.failure(error), forAppWithBundleIdentifier: operation.bundleIdentifier) - + + self.invalidateAuthenticationIfNeeded(for: nsError) self.log(error, operation: operation.loggedErrorOperation, app: operation.app) } } diff --git a/AltStore/Operations/AuthenticationOperation.swift b/AltStore/Operations/AuthenticationOperation.swift index 7f43360759..4ac07b6a17 100644 --- a/AltStore/Operations/AuthenticationOperation.swift +++ b/AltStore/Operations/AuthenticationOperation.swift @@ -37,11 +37,56 @@ enum AuthenticationErrorCode: Int, ALTErrorEnum, CaseIterable } } +/// The result of the most recent successful sign-in, kept so later operations can reuse it +/// instead of signing in again. Apple limits how often an account may sign in. +final class AuthenticationCache +{ + struct Entry + { + let team: ALTTeam + let certificate: ALTCertificate + let session: ALTAppleAPISession + let date: Date + } + + /// How long a sign-in is reused before the next operation signs in again. + static let lifetime: TimeInterval = 15 * 60 + + private var entry: Entry? + private let lock = NSLock() + + var validEntry: Entry? { + self.lock.lock() + defer { self.lock.unlock() } + + guard let entry = self.entry, Date().timeIntervalSince(entry.date) < AuthenticationCache.lifetime else { return nil } + return entry + } + + func store(team: ALTTeam, certificate: ALTCertificate, session: ALTAppleAPISession) + { + self.lock.lock() + defer { self.lock.unlock() } + + self.entry = Entry(team: team, certificate: certificate, session: session, date: Date()) + } + + func invalidate() + { + self.lock.lock() + defer { self.lock.unlock() } + + self.entry = nil + } +} + @objc(AuthenticationOperation) class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppleAPISession)>, @unchecked Sendable { let context: AuthenticatedOperationContext - + + private var isReusingSession = false + private var presentingViewController: UIViewController? { return self.context.presentingViewController } @@ -88,8 +133,42 @@ class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppl self.finish(.failure(error)) return } - - // Sign In + + // Reuse the last sign-in while it is valid; only the anisette data must be fresh. + if let entry = AppManager.shared.authenticationCache.validEntry + { + self.fetchAnisetteData { (result) in + guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } + + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success(let anisetteData) where anisetteData.machineID == entry.session.anisetteData.machineID: + self.isReusingSession = true + + let session = ALTAppleAPISession(dsid: entry.session.dsid, authToken: entry.session.authToken, anisetteData: anisetteData) + self.context.session = session + self.context.team = entry.team + self.context.certificate = entry.certificate + self.progress.completedUnitCount = self.progress.totalUnitCount + + self.finish(.success((entry.team, entry.certificate, session))) + + case .success: + // The anisette identity changed since the sign-in, so its token no longer matches it. + AppManager.shared.authenticationCache.invalidate() + self.performSignIn() + } + } + + return + } + + self.performSignIn() + } + + private func performSignIn() + { self.signIn() { (result) in guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } @@ -213,9 +292,19 @@ class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppl switch result { case .failure(let error): Logger.sideload.error("Failed to authenticate account. \(error.localizedDescription, privacy: .public)") - case .success((let team, _, _)): Logger.sideload.notice("Authenticated account for team \(team.identifier, privacy: .public).") + case .success((let team, _, _)) where self.isReusingSession: Logger.sideload.notice("Reused sign-in for team \(team.identifier, privacy: .public).") + case .success((let team, let certificate, let session)): + Logger.sideload.notice("Authenticated account for team \(team.identifier, privacy: .public).") + AppManager.shared.authenticationCache.store(team: team, certificate: certificate, session: session) } - + + if self.isReusingSession + { + // The sign-in this session came from already updated the database and keychain. + super.finish(result) + return + } + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() context.perform { do @@ -382,6 +471,13 @@ private extension AuthenticationOperation } } + func fetchAnisetteData(completionHandler: @escaping (Result) -> Void) + { + let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context) + fetchAnisetteDataOperation.resultHandler = completionHandler + self.operationQueue.addOperation(fetchAnisetteDataOperation) + } + func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void) { self.appleIDEmailAddress = appleID @@ -404,8 +500,7 @@ private extension AuthenticationOperation self.operationQueue.addOperation(resyncOperation) } - let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context) - fetchAnisetteDataOperation.resultHandler = { (result) in + self.fetchAnisetteData { (result) in switch result { case .failure(let error): completionHandler(.failure(error)) @@ -469,8 +564,6 @@ private extension AuthenticationOperation } } } - - self.operationQueue.addOperation(fetchAnisetteDataOperation) } func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result) -> Void) diff --git a/AltStore/Settings/SettingsViewController.swift b/AltStore/Settings/SettingsViewController.swift index cb9e5b995b..99ec825d11 100644 --- a/AltStore/Settings/SettingsViewController.swift +++ b/AltStore/Settings/SettingsViewController.swift @@ -468,6 +468,8 @@ private extension SettingsViewController { func signOut() { + AppManager.shared.authenticationCache.invalidate() + DatabaseManager.shared.signOut { (error) in DispatchQueue.main.async { if let error = error