From ad267e2b56217cb10c9e706b95e2b85d89cdc6be Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sat, 28 Nov 2020 13:02:17 -0700 Subject: [PATCH] 2FA seems to work now --- .../contents.xcworkspacedata | 2 +- Xcodes/AppState.swift | 199 +++++++-- Xcodes/AppleAPI/Package.swift | 10 +- Xcodes/AppleAPI/Sources/AppleAPI/Client.swift | 404 +++++++++--------- .../Sources/AppleAPI/Environment.swift | 20 +- Xcodes/ContentView.swift | 20 +- Xcodes/SignIn/SignIn2FAView.swift | 5 +- Xcodes/SignIn/SignInCredentialsView.swift | 2 +- Xcodes/XcodesKit/Package.swift | 2 +- .../Sources/XcodesKit/Environment.swift | 12 +- .../Sources/XcodesKit/XcodeInstaller.swift | 124 +++--- 11 files changed, 457 insertions(+), 343 deletions(-) diff --git a/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1091406..919434a 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/Xcodes/AppState.swift b/Xcodes/AppState.swift index d34df78..c6829a7 100644 --- a/Xcodes/AppState.swift +++ b/Xcodes/AppState.swift @@ -22,6 +22,8 @@ class AppState: ObservableObject { case installing(Progress) case installed } + + @Published var authenticationState: AuthenticationState = .unauthenticated @Published var allVersions: [XcodeVersion] = [] struct AlertContent: Identifiable { @@ -32,53 +34,184 @@ class AppState: ObservableObject { @Published var error: AlertContent? @Published var presentingSignInAlert = false + @Published var secondFactorSessionData: AppleSessionData? + + private var cancellables = Set() + let client = AppleAPI.Client() func load() { // if list.shouldUpdate { update() - .done { _ in - self.updateAllVersions() - } - .catch { error in - self.error = AlertContent(title: "Error", - message: error.localizedDescription) - } -// } +// .done { _ in +// self.updateAllVersions() +// } +// .catch { error in +// self.error = AlertContent(title: "Error", +// message: error.localizedDescription) +// } +//// } // else { // updateAllVersions() // } } - func validateSession() -> Promise { - return firstly { () -> Promise in - return Current.network.validateSession() - } - .recover { _ in - self.presentingSignInAlert = true + func validateSession() -> AnyPublisher { + return client.validateSession() + .handleEvents(receiveCompletion: { completion in + if case .failure = completion { + self.authenticationState = .unauthenticated + self.presentingSignInAlert = true + } + }) + .eraseToAnyPublisher() + } + + func login(username: String, password: String) { + client.login(accountName: username, password: password) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + if case .failure = completion { + // TODO: show error + } + }, + receiveValue: { authenticationState in + self.authenticationState = authenticationState + if case let AuthenticationState.waitingForSecondFactor(option, sessionData) = authenticationState { + self.handleTwoFactorOption(option, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + } + } + ) + .store(in: &cancellables) + } + + func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) { +// Current.logging.log("Two-factor authentication is enabled for this account.\n") + switch option { + case let .smsSent(codeLength, phoneNumber): + break +// return Result { +// let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber) +// return try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code) +// } +// .publisher +// .flatMap { request in +// return Current.network.dataTask(with: request) +// .validateSecurityCodeResponse() +// .mapError { $0 as Error } +// } +// .flatMap { (data, response) in +// self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) +// } +// .eraseToAnyPublisher() + case let .smsPendingChoice(codeLength, trustedPhoneNumbers): + break +// return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + case let .codeSent(codeLength): + self.presentingSignInAlert = false + self.secondFactorSessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) +// let code = Current.shell.readLine(""" +// Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to. +// Enter the \(codeLength) digit code from one of your trusted devices: +// """) ?? "" +// +// if code == "sms" { + // return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) +// } } } - func continueLogin(username: String, password: String) -> Promise { - firstly { () -> Promise in - self.installer.login(username, password: password) - } - .recover { error -> Promise in - XcodesKit.Current.logging.log(error.legibleLocalizedDescription) - - if case Client.Error.invalidUsernameOrPassword = error { - self.presentingSignInAlert = true - } - return Promise(error: error) - } +// func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> AnyPublisher { +// return Result { +// Current.logging.log("Trusted phone numbers:") +// trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in +// Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)") +// } +// +// let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ") +// guard +// let selectionNumberString = possibleSelectionNumberString, +// let selectionNumber = Int(selectionNumberString) , +// trustedPhoneNumbers.indices.contains(selectionNumber - 1) +// else { +// throw AuthenticationError.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString) +// } +// +// return trustedPhoneNumbers[selectionNumber - 1] +// } +// .publisher +// .catch { error -> AnyPublisher in +// guard case AuthenticationError.invalidPhoneNumberIndex = error else { +// return Fail(error: error).eraseToAnyPublisher() +// } +// Current.logging.log("\(error.localizedDescription)\n") +// return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers) +// } +// .eraseToAnyPublisher() +// } +// +// func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode { +// let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? "" +// return .sms(code: code, phoneNumberId: trustedPhoneNumber.id) +// } + +// func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher { +// // I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number, +// // but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing. +// guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else { +// return Fail(error: AuthenticationError.noTrustedPhoneNumbers) +// .eraseToAnyPublisher() +// } +// +// return selectPhoneNumberInteractively(from: trustedPhoneNumbers) +// .flatMap { trustedPhoneNumber in +// Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id)) +// .map { _ in +// self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber) +// } +// } +// .flatMap { code in +// Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) +// .validateSecurityCodeResponse() +// } +// .flatMap { (data, response) -> AnyPublisher in +// self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) +// } +// .eraseToAnyPublisher() +// } + + func submit2FACode(_ code: String, sessionData: AppleSessionData) { + client.submitSecurityCode(code, sessionData: sessionData) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + switch completion { + case let .failure(error): + self.error = AlertContent(title: "Error logging in", message: error.legibleLocalizedDescription) + case .finished: + if case .authenticated = self.authenticationState { + self.presentingSignInAlert = false + self.secondFactorSessionData = nil + } + } + }, + receiveValue: { authenticationState in + self.authenticationState = authenticationState + } + ) + .store(in: &cancellables) } - public func update() -> Promise<[Xcode]> { - return firstly { () -> Promise in - validateSession() - } - .then { () -> Promise<[Xcode]> in - self.list.update() - } + public func update() -> AnyPublisher<[Xcode], Error> { +// return firstly { () -> Promise in +// validateSession() +// } +// .then { () -> Promise<[Xcode]> in +// self.list.update() +// } + Just<[Xcode]>([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } private func updateAllVersions() { diff --git a/Xcodes/AppleAPI/Package.swift b/Xcodes/AppleAPI/Package.swift index bfab7a9..b4c2d5c 100644 --- a/Xcodes/AppleAPI/Package.swift +++ b/Xcodes/AppleAPI/Package.swift @@ -5,24 +5,20 @@ import PackageDescription let package = Package( name: "AppleAPI", - platforms: [.macOS(.v10_13)], + platforms: [.macOS(.v10_15)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "AppleAPI", targets: ["AppleAPI"]), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")), - .package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")), - ], + dependencies: [], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "AppleAPI", - dependencies: ["PromiseKit", "PMKFoundation"]), + dependencies: []), .testTarget( name: "AppleAPITests", dependencies: ["AppleAPI"]), diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift index 8617915..a3f1dbd 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift @@ -1,123 +1,84 @@ import Foundation -import PromiseKit -import PMKFoundation +import Combine public class Client { private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] public init() {} - public enum Error: Swift.Error, LocalizedError, Equatable { - case invalidSession - case invalidUsernameOrPassword(username: String) - case invalidPhoneNumberIndex(min: Int, max: Int, given: String?) - case incorrectSecurityCode - case unexpectedSignInResponse(statusCode: Int, message: String?) - case appleIDAndPrivacyAcknowledgementRequired - case noTrustedPhoneNumbers + // MARK: - Login - public var errorDescription: String? { - switch self { - case .invalidUsernameOrPassword(let username): - return "Invalid username and password combination. Attempted to sign in with username \(username)." - case .appleIDAndPrivacyAcknowledgementRequired: - return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." - case .invalidPhoneNumberIndex(let min, let max, let given): - return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." - case .noTrustedPhoneNumbers: - return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915." - default: - return String(describing: self) - } - } - } - - /// Use the olympus session endpoint to see if the existing session is still valid - public func validateSession() -> Promise { - return Current.network.dataTask(with: URLRequest.olympusSession) - .done { data, response in - guard - let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], - jsonObject["provider"] != nil - else { throw Error.invalidSession } - } - } - - public func login(accountName: String, password: String) -> Promise { + public func login(accountName: String, password: String) -> AnyPublisher { var serviceKey: String! - return firstly { () -> Promise<(data: Data, response: URLResponse)> in - Current.network.dataTask(with: URLRequest.itcServiceKey) - } - .then { (data, _) -> Promise<(data: Data, response: URLResponse)> in - struct ServiceKeyResponse: Decodable { - let authServiceKey: String + return Current.network.dataTask(with: URLRequest.itcServiceKey) + .map(\.data) + .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder()) + .flatMap { serviceKeyResponse -> AnyPublisher in + serviceKey = serviceKeyResponse.authServiceKey + return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password)) + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() } - - let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) - serviceKey = response.authServiceKey - - return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password)) - } - .then { (data, response) -> Promise in - struct SignInResponse: Decodable { - let authType: String? - let serviceErrors: [ServiceError]? - - struct ServiceError: Decodable, CustomStringConvertible { - let code: String - let message: String - - var description: String { - return "\(code): \(message)" + .flatMap { result -> AnyPublisher in + let (data, response) = result + return Just(data) + .decode(type: SignInResponse.self, decoder: JSONDecoder()) + .flatMap { responseBody -> AnyPublisher in + let httpResponse = response as! HTTPURLResponse + + switch httpResponse.statusCode { + case 200: + return Current.network.dataTask(with: URLRequest.olympusSession) + .map { _ in AuthenticationState.authenticated } + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() + case 401: + return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName)) + .eraseToAnyPublisher() + case 409: + return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) + case 412 where Client.authTypes.contains(responseBody.authType ?? ""): + return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired) + .eraseToAnyPublisher() + default: + return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode, + message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))) + .eraseToAnyPublisher() + } } - } + .eraseToAnyPublisher() } - - let httpResponse = response as! HTTPURLResponse - let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data) - - switch httpResponse.statusCode { - case 200: - return Current.network.dataTask(with: URLRequest.olympusSession).asVoid() - case 401: - throw Error.invalidUsernameOrPassword(username: accountName) - case 409: - return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) - case 412 where Client.authTypes.contains(responseBody.authType ?? ""): - throw Error.appleIDAndPrivacyAcknowledgementRequired - default: - throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode, - message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")) - } - } + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() } - func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise { + func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher { let httpResponse = response as! HTTPURLResponse let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String) let scnt = (httpResponse.allHeaderFields["scnt"] as! String) - return firstly { () -> Promise in - return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) } - } - .then { authOptions -> Promise in - switch authOptions.kind { - case .twoStep: - Current.logging.log("Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new") - return Promise.value(()) - case .twoFactor: - return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) - case .unknown: - Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:") - String(data: data, encoding: .utf8).map { Current.logging.log($0) } - return Promise.value(()) + return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) + .map(\.data) + .decode(type: AuthOptionsResponse.self, decoder: JSONDecoder()) + .flatMap { authOptions -> AnyPublisher in + switch authOptions.kind { + case .twoStep: + return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication) + .eraseToAnyPublisher() + case .twoFactor: + return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) + .eraseToAnyPublisher() + case .unknown: + let possibleResponseString = String(data: data, encoding: .utf8) + return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString)) + .eraseToAnyPublisher() + } } - } + .eraseToAnyPublisher() } - func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise { + func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher { let option: TwoFactorOption // SMS was sent automatically @@ -131,132 +92,153 @@ public class Client { option = .codeSent(authOptions.securityCode.length) } - return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + return Just(AuthenticationState.waitingForSecondFactor(option, sessionData)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + // return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) } - func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) -> Promise { - Current.logging.log("Two-factor authentication is enabled for this account.\n") - switch option { - case let .smsSent(codeLength, phoneNumber): - return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in - let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber) - return Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) - .validateSecurityCodeResponse() - } - .then { (data, response) -> Promise in - self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } - case let .smsPendingChoice(codeLength, trustedPhoneNumbers): - return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - case let .codeSent(codeLength): - let code = Current.shell.readLine(""" - Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to. - Enter the \(codeLength) digit code from one of your trusted devices: - """) ?? "" - - if code == "sms" { - // return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } - - return firstly { - Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code))) - .validateSecurityCodeResponse() - - } - .then { (data, response) -> Promise in - self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } + // MARK: - Continue 2FA + + public func submitSecurityCode(_ code: String, sessionData: AppleSessionData) -> AnyPublisher { + Result { + try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: .device(code: code)) } - } - - func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise { - return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .then { (data, response) -> Promise in - Current.network.dataTask(with: URLRequest.olympusSession).asVoid() - } - } - - func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise { - return firstly { () throws -> Guarantee in - Current.logging.log("Trusted phone numbers:") - trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in - Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)") - } - - let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ") - guard - let selectionNumberString = possibleSelectionNumberString, - let selectionNumber = Int(selectionNumberString) , - trustedPhoneNumbers.indices.contains(selectionNumber - 1) - else { - throw Error.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString) - } - - return .value(trustedPhoneNumbers[selectionNumber - 1]) - } - .recover { error throws -> Promise in - guard case Error.invalidPhoneNumberIndex = error else { throw error } - Current.logging.log("\(error.localizedDescription)\n") - return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers) - } - } - - func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode { - let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? "" - return .sms(code: code, phoneNumberId: trustedPhoneNumber.id) - } - - func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> Promise { - return firstly { () throws -> Promise in - // I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number, - // but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing. - guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else { - throw Error.noTrustedPhoneNumbers - } - - return selectPhoneNumberInteractively(from: trustedPhoneNumbers) - } - .then { trustedPhoneNumber in - Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id)) - .map { _ in - self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber) + .publisher + .flatMap { request in + Current.network.dataTask(with: request) + .mapError { $0 as Error } + // .validateSecurityCodeResponse() + .flatMap { (data, response) -> AnyPublisher in + self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) } } - .then { code in - Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) - .validateSecurityCodeResponse() - } - .then { (data, response) -> Promise in - self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + .eraseToAnyPublisher() + } + + // MARK: - Session + + /// Use the olympus session endpoint to see if the existing session is still valid + public func validateSession() -> AnyPublisher { + return Current.network.dataTask(with: URLRequest.olympusSession) + .tryMap { (data, response) in + guard + let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + jsonObject["provider"] != nil + else { throw AuthenticationError.invalidSession } + } + .eraseToAnyPublisher() + } + + func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher { + return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) + .flatMap { (data, response) in + Current.network.dataTask(with: URLRequest.olympusSession) + .map { _ in AuthenticationState.authenticated } + } + .mapError { $0 as Error } + .eraseToAnyPublisher() + } +} + +// MARK: - Types + +public enum AuthenticationState: Equatable { + case unauthenticated + case waitingForSecondFactor(TwoFactorOption, AppleSessionData) + case authenticated +} + +public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { + case invalidSession + case invalidUsernameOrPassword(username: String) + case invalidPhoneNumberIndex(min: Int, max: Int, given: String?) + case incorrectSecurityCode + case unexpectedSignInResponse(statusCode: Int, message: String?) + case appleIDAndPrivacyAcknowledgementRequired + case noTrustedPhoneNumbers + case accountUsesTwoStepAuthentication + case accountUsesUnknownAuthenticationKind(String?) + + public var errorDescription: String? { + switch self { + case .invalidUsernameOrPassword(let username): + return "Invalid username and password combination. Attempted to sign in with username \(username)." + case .appleIDAndPrivacyAcknowledgementRequired: + return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." + case .invalidPhoneNumberIndex(let min, let max, let given): + return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." + case .noTrustedPhoneNumbers: + return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915." + case .accountUsesTwoStepAuthentication: + return "Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new" + case .accountUsesUnknownAuthenticationKind: + return "Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:" + default: + return String(describing: self) } } } -enum TwoFactorOption { +public struct AppleSessionData: Equatable, Identifiable { + public let serviceKey: String + public let sessionID: String + public let scnt: String + + public var id: String { sessionID } + + public init(serviceKey: String, sessionID: String, scnt: String) { + self.serviceKey = serviceKey + self.sessionID = sessionID + self.scnt = scnt + } +} + +struct ServiceKeyResponse: Decodable { + let authServiceKey: String +} + +struct SignInResponse: Decodable { + let authType: String? + let serviceErrors: [ServiceError]? + + struct ServiceError: Decodable, CustomStringConvertible { + let code: String + let message: String + + var description: String { + return "\(code): \(message)" + } + } +} + +public enum TwoFactorOption: Equatable { case smsSent(Int, AuthOptionsResponse.TrustedPhoneNumber) case codeSent(Int) case smsPendingChoice(Int, [AuthOptionsResponse.TrustedPhoneNumber]) } -public extension Promise where T == (data: Data, response: URLResponse) { - func validateSecurityCodeResponse() -> Promise { - validate() - .recover { error -> Promise<(data: Data, response: URLResponse)> in - switch error { - case PMKHTTPError.badStatusCode(let code, _, _): - if code == 401 { - throw Client.Error.incorrectSecurityCode - } else { - throw error - } - default: - throw error - } - } +public extension Publisher where Output == (data: Data, response: URLResponse) { + func validateSecurityCodeResponse() -> AnyPublisher { + self.eraseToAnyPublisher() +// validate() +// .recover { error -> AnyPublisher in +// switch error { +// case PMKHTTPError.badStatusCode(let code, _, _): +// if code == 401 { +// throw Client.Error.incorrectSecurityCode +// } else { +// throw error +// } +// default: +// throw error +// } +// } } } -struct AuthOptionsResponse: Decodable { +public struct AuthOptionsResponse: Equatable, Decodable { let trustedPhoneNumbers: [TrustedPhoneNumber]? let trustedDevices: [TrustedDevice]? let securityCode: SecurityCodeInfo @@ -285,18 +267,18 @@ struct AuthOptionsResponse: Decodable { trustedPhoneNumbers?.count == 1 && canFallBackToSMS } - struct TrustedPhoneNumber: Decodable { + public struct TrustedPhoneNumber: Equatable, Decodable { let id: Int let numberWithDialCode: String } - struct TrustedDevice: Decodable { + public struct TrustedDevice: Equatable, Decodable { let id: String let name: String let modelName: String } - struct SecurityCodeInfo: Decodable { + public struct SecurityCodeInfo: Equatable, Decodable { let length: Int let tooManyCodesSent: Bool let tooManyCodesValidated: Bool @@ -304,7 +286,7 @@ struct AuthOptionsResponse: Decodable { let securityCodeCooldown: Bool } - enum Kind { + public enum Kind: Equatable { case twoStep, twoFactor, unknown } } diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift index 3977321..5c76749 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift @@ -1,6 +1,5 @@ import Foundation -import PromiseKit -import PMKFoundation +import Combine /** Lightweight dependency injection using global mutable state :P @@ -10,29 +9,18 @@ import PMKFoundation - SeeAlso: https://vimeo.com/291588126 */ public struct Environment { - public var shell = Shell() public var network = Network() public var logging = Logging() } public var Current = Environment() -public struct Shell { - public var readLine: (String) -> String? = { prompt in - print(prompt, terminator: "") - return Swift.readLine() - } - public func readLine(prompt: String) -> String? { - readLine(prompt) - } -} - public struct Network { public var session = URLSession.shared - public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { Current.network.session.dataTask(.promise, with: $0) } - public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> { - dataTask(convertible) + public var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { Current.network.session.dataTaskPublisher(for: $0) } + public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher { + dataTask(request) } } diff --git a/Xcodes/ContentView.swift b/Xcodes/ContentView.swift index ff4048b..0f35eff 100644 --- a/Xcodes/ContentView.swift +++ b/Xcodes/ContentView.swift @@ -67,7 +67,12 @@ struct ContentView: View { } } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { + Button("Login", action: { self.appState.presentingSignInAlert = true }) + .sheet(isPresented: $appState.presentingSignInAlert) { + SignInCredentialsView(isPresented: $appState.presentingSignInAlert) + .environmentObject(appState) + } Button(action: { self.appState.update() }) { Image(systemName: "arrow.clockwise") } @@ -102,10 +107,11 @@ struct ContentView: View { primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }), secondaryButton: .cancel(Text("Cancel"))) } - .sheet(isPresented: $appState.presentingSignInAlert) { - SignInCredentialsView(isPresented: $appState.presentingSignInAlert) + .sheet(item: $appState.secondFactorSessionData) { sessionData in + SignIn2FAView(isPresented: $appState.secondFactorSessionData.isNotNil, sessionData: sessionData) .environmentObject(appState) } + } } @@ -127,3 +133,11 @@ struct ContentView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) } } + +extension Optional { + /// Note that this is lossy when setting, so you can really only set it to nil, but this is sufficient for mapping `Binding` to `Binding` for Alerts, Popovers, etc. + var isNotNil: Bool { + get { self != nil } + set { self = newValue ? self : nil } + } +} diff --git a/Xcodes/SignIn/SignIn2FAView.swift b/Xcodes/SignIn/SignIn2FAView.swift index deb46b6..ede4e16 100644 --- a/Xcodes/SignIn/SignIn2FAView.swift +++ b/Xcodes/SignIn/SignIn2FAView.swift @@ -5,6 +5,7 @@ struct SignIn2FAView: View { @EnvironmentObject var appState: AppState @Binding var isPresented: Bool @State private var code: String = "" + let sessionData: AppleSessionData var body: some View { VStack(alignment: .leading) { @@ -18,7 +19,7 @@ struct SignIn2FAView: View { Button("Cancel", action: { isPresented = false }) Button("Send SMS", action: {}) Spacer() - Button("Continue", action: {}) + Button("Continue", action: { appState.submit2FACode(code, sessionData: sessionData) }) } } .padding() @@ -27,7 +28,7 @@ struct SignIn2FAView: View { struct SignIn2FAView_Previews: PreviewProvider { static var previews: some View { - SignIn2FAView(isPresented: .constant(true)) + SignIn2FAView(isPresented: .constant(true), sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")) .environmentObject(AppState()) } } diff --git a/Xcodes/SignIn/SignInCredentialsView.swift b/Xcodes/SignIn/SignInCredentialsView.swift index f1de68b..05a2a2b 100644 --- a/Xcodes/SignIn/SignInCredentialsView.swift +++ b/Xcodes/SignIn/SignInCredentialsView.swift @@ -28,7 +28,7 @@ struct SignInCredentialsView: View { Spacer() Button("Cancel") { isPresented = false } .keyboardShortcut(.cancelAction) - Button("Next") { appState.continueLogin(username: username, password: password) } + Button("Next") { appState.login(username: username, password: password) } .disabled(username.isEmpty) .keyboardShortcut(.defaultAction) } diff --git a/Xcodes/XcodesKit/Package.swift b/Xcodes/XcodesKit/Package.swift index 6bdda8e..9e200a4 100644 --- a/Xcodes/XcodesKit/Package.swift +++ b/Xcodes/XcodesKit/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "XcodesKit", - platforms: [.macOS(.v10_13)], + platforms: [.macOS(.v10_15)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift index bdb10af..5db9b5e 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift @@ -246,12 +246,12 @@ public struct Network { return downloadTask(convertible, saveLocation, resumeData) } - public var validateSession: () -> Promise = client.validateSession - - public var login: (String, String) -> Promise = { client.login(accountName: $0, password: $1) } - public func login(accountName: String, password: String) -> Promise { - login(accountName, password) - } +// public var validateSession: () -> Promise = client.validateSession +// +// public var login: (String, String) -> Promise = { client.login(accountName: $0, password: $1) } +// public func login(accountName: String, password: String) -> Promise { +// login(accountName, password) +// } } public struct Logging { diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift b/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift index 2ade8f1..f89d7f8 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift @@ -248,9 +248,9 @@ public final class XcodeInstaller { private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> { return firstly { () -> Promise in - loginIfNeeded().map { version } - } - .then { version -> Promise in +// loginIfNeeded().map { version } +// } +// .then { version -> Promise in if self.xcodeList.shouldUpdate { return self.xcodeList.update().map { _ in version } } @@ -282,60 +282,60 @@ public final class XcodeInstaller { } } - func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise { - return firstly { () -> Promise in - return Current.network.validateSession() - } - .recover { error -> Promise in - guard - let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "), - let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ") - else { throw Error.missingUsernameOrPassword } - - return firstly { () -> Promise in - self.login(username, password: password) - } - .recover { error -> Promise in - Current.logging.log(error.legibleLocalizedDescription) - - if case Client.Error.invalidUsernameOrPassword = error { - Current.logging.log("Try entering your password again") - return self.loginIfNeeded(withUsername: username) - } - else { - return Promise(error: error) - } - } - } - } - - public func login(_ username: String, password: String) -> Promise { - return firstly { () -> Promise in - Current.network.login(accountName: username, password: password) - } - .recover { error -> Promise in - - if let error = error as? Client.Error { - switch error { - case .invalidUsernameOrPassword(_): - // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. - try? Current.keychain.remove(username) - default: - break - } - } - - return Promise(error: error) - } - .done { _ in - try? Current.keychain.set(password, key: username) - - if self.configuration.defaultUsername != username { - self.configuration.defaultUsername = username - try? self.configuration.save() - } - } - } +// func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise { +// return firstly { () -> Promise in +// return Current.network.validateSession() +// } +// .recover { error -> Promise in +// guard +// let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "), +// let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ") +// else { throw Error.missingUsernameOrPassword } +// +// return firstly { () -> Promise in +// self.login(username, password: password) +// } +// .recover { error -> Promise in +// Current.logging.log(error.legibleLocalizedDescription) +// +// if case Client.AuthenticationErrro.invalidUsernameOrPassword = error { +// Current.logging.log("Try entering your password again") +// return self.loginIfNeeded(withUsername: username) +// } +// else { +// return Promise(error: error) +// } +// } +// } +// } +// +// public func login(_ username: String, password: String) -> Promise { +// return firstly { () -> Promise in +// Current.network.login(accountName: username, password: password) +// } +// .recover { error -> Promise in +// +// if let error = error as? Client.AuthenticationErrro { +// switch error { +// case .invalidUsernameOrPassword(_): +// // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. +// try? Current.keychain.remove(username) +// default: +// break +// } +// } +// +// return Promise(error: error) +// } +// .done { _ in +// try? Current.keychain.set(password, key: username) +// +// if self.configuration.defaultUsername != username { +// self.configuration.defaultUsername = username +// try? self.configuration.save() +// } +// } +// } let xcodesUsername = "XCODES_USERNAME" let xcodesPassword = "XCODES_PASSWORD" @@ -511,12 +511,12 @@ public final class XcodeInstaller { } public func update() -> Promise<[Xcode]> { - return firstly { () -> Promise in - loginIfNeeded() - } - .then { () -> Promise<[Xcode]> in +// return firstly { () -> Promise in +// loginIfNeeded() +// } +// .then { () -> Promise<[Xcode]> in self.xcodeList.update() - } +// } } public func updateAndPrint(destination: Path) -> Promise {