2FA seems to work now

This commit is contained in:
Brandon Evans 2020-11-28 13:02:17 -07:00
parent a081a1392e
commit ad267e2b56
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
11 changed files with 457 additions and 343 deletions

View file

@ -2,6 +2,6 @@
<Workspace
version = "1.0">
<FileRef
location = "self:/Users/brandon/Projects/XcodesApp/Xcodes.xcodeproj">
location = "self:">
</FileRef>
</Workspace>

View file

@ -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<AnyCancellable>()
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<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
.recover { _ in
self.presentingSignInAlert = true
func validateSession() -> AnyPublisher<Void, Error> {
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<Void> {
firstly { () -> Promise<Void> in
self.installer.login(username, password: password)
}
.recover { error -> Promise<Void> 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<AuthOptionsResponse.TrustedPhoneNumber, Swift.Error> {
// 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<AuthOptionsResponse.TrustedPhoneNumber, Swift.Error> in
// guard case AuthenticationError.invalidPhoneNumberIndex = error else {
// return Fail<AuthOptionsResponse.TrustedPhoneNumber, Swift.Error>(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<AuthenticationState, Error> {
// // 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<AuthenticationState, Error> 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<Void> in
validateSession()
}
.then { () -> Promise<[Xcode]> in
self.list.update()
}
public func update() -> AnyPublisher<[Xcode], Error> {
// return firstly { () -> Promise<Void> in
// validateSession()
// }
// .then { () -> Promise<[Xcode]> in
// self.list.update()
// }
Just<[Xcode]>([])
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
private func updateAllVersions() {

View file

@ -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"]),

View file

@ -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<Void> {
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<Void> {
public func login(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
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<URLSession.DataTaskPublisher.Output, Swift.Error> 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<Void> 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<AuthenticationState, Swift.Error> in
let (data, response) = result
return Just(data)
.decode(type: SignInResponse.self, decoder: JSONDecoder())
.flatMap { responseBody -> AnyPublisher<AuthenticationState, Swift.Error> 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<Void> {
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
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<AuthOptionsResponse> 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<Void> 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<AuthenticationState, Error> 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<Void> {
func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher<AuthenticationState, Error> {
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<Void> {
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<Void> 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<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
// MARK: - Continue 2FA
public func submitSecurityCode(_ code: String, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
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<Void> {
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.then { (data, response) -> Promise<Void> in
Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
}
}
func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise<AuthOptionsResponse.TrustedPhoneNumber> {
return firstly { () throws -> Guarantee<AuthOptionsResponse.TrustedPhoneNumber> 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<AuthOptionsResponse.TrustedPhoneNumber> 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<Void> {
return firstly { () throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> 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<AuthenticationState, Error> 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<Void> 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<Void, Error> {
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<AuthenticationState, Error> {
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<T> {
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<Output, Failure> {
self.eraseToAnyPublisher()
// validate()
// .recover { error -> AnyPublisher<Output, Swift.Error> 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
}
}

View file

@ -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)
}
}

View file

@ -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<Item?>` to `Binding<Bool>` for Alerts, Popovers, etc.
var isNotNil: Bool {
get { self != nil }
set { self = newValue ? self : nil }
}
}

View file

@ -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())
}
}

View file

@ -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)
}

View file

@ -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(

View file

@ -246,12 +246,12 @@ public struct Network {
return downloadTask(convertible, saveLocation, resumeData)
}
public var validateSession: () -> Promise<Void> = client.validateSession
public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
public func login(accountName: String, password: String) -> Promise<Void> {
login(accountName, password)
}
// public var validateSession: () -> Promise<Void> = client.validateSession
//
// public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
// public func login(accountName: String, password: String) -> Promise<Void> {
// login(accountName, password)
// }
}
public struct Logging {

View file

@ -248,9 +248,9 @@ public final class XcodeInstaller {
private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<Version> in
loginIfNeeded().map { version }
}
.then { version -> Promise<Version> in
// loginIfNeeded().map { version }
// }
// .then { version -> Promise<Version> 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<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
.recover { error -> Promise<Void> 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<Void> in
self.login(username, password: password)
}
.recover { error -> Promise<Void> 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<Void> {
return firstly { () -> Promise<Void> in
Current.network.login(accountName: username, password: password)
}
.recover { error -> Promise<Void> 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<Void> {
// return firstly { () -> Promise<Void> in
// return Current.network.validateSession()
// }
// .recover { error -> Promise<Void> 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<Void> in
// self.login(username, password: password)
// }
// .recover { error -> Promise<Void> 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<Void> {
// return firstly { () -> Promise<Void> in
// Current.network.login(accountName: username, password: password)
// }
// .recover { error -> Promise<Void> 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<Void> in
loginIfNeeded()
}
.then { () -> Promise<[Xcode]> in
// return firstly { () -> Promise<Void> in
// loginIfNeeded()
// }
// .then { () -> Promise<[Xcode]> in
self.xcodeList.update()
}
// }
}
public func updateAndPrint(destination: Path) -> Promise<Void> {