Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for 2FA accounts using SMS #107

Merged
merged 5 commits into from
Oct 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ jobs:
- uses: actions/checkout@v2.3.3
- name: Run tests
env:
DEVELOPER_DIR: /Applications/Xcode_11.6.app
run: swift test
DEVELOPER_DIR: /Applications/Xcode_12.app
# Normally would use `swift test` but there's a bug related to SPM package resources, so using xcodebuild as a workaround
# https://forums.swift.org/t/swift-5-3-spm-resources-in-tests-uses-wrong-bundle-path/37051/10
run: xcodebuild test -scheme xcodes
2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.2
5.3
Binary file modified Apple.paw
Binary file not shown.
12 changes: 9 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.2
// swift-tools-version:5.3
import PackageDescription

let package = Package(
Expand All @@ -19,7 +19,7 @@ let package = Package(
.package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMinor(from: "3.3.1")),
.package(url: "https://github.com/scinfu/SwiftSoup.git", .upToNextMinor(from: "2.0.0")),
.package(url: "https://github.com/mxcl/LegibleError.git", .upToNextMinor(from: "1.0.1")),
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMinor(from: "3.2.0"))
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMinor(from: "3.2.0")),
],
targets: [
.target(
Expand All @@ -39,6 +39,9 @@ let package = Package(
name: "XcodesKitTests",
dependencies: [
"XcodesKit", "Version"
],
resources: [
.copy("Fixtures"),
]),
.target(
name: "AppleAPI",
Expand All @@ -47,6 +50,9 @@ let package = Package(
]),
.testTarget(
name: "AppleAPITests",
dependencies: ["AppleAPI"]),
dependencies: ["AppleAPI"],
resources: [
.copy("Fixtures"),
]),
]
)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ mint install RobotsAndPencils/xcodes
<details>
<summary>Build from source</summary>

Building from source requires Xcode 11.0 or later, so it's not an option for setting up a computer from scratch.
Building from source requires Xcode 12.0 or later, so it's not an option for setting up a computer from scratch.

```sh
git clone https://github.com/RobotsAndPencils/xcodes
Expand Down Expand Up @@ -98,7 +98,7 @@ Xcode 11.2.0 has been installed to /Applications/Xcode-11.2.0.app

## Development

You'll need Xcode 11 in order to build and run xcodes.
You'll need Xcode 12 in order to build and run xcodes.

<details>
<summary>Using Xcode</summary>
Expand Down
233 changes: 215 additions & 18 deletions Sources/AppleAPI/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@ import PromiseKit
import PMKFoundation

public class Client {
private(set) public var session = URLSession.shared
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]

public init() {}

public enum Error: Swift.Error, LocalizedError {
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

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)
}
Expand All @@ -28,7 +34,7 @@ public class Client {

/// Use the olympus session endpoint to see if the existing session is still valid
public func validateSession() -> Promise<Void> {
return session.dataTask(.promise, with: URLRequest.olympusSession)
return Current.network.dataTask(with: URLRequest.olympusSession)
.done { data, response in
guard
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
Expand All @@ -41,7 +47,7 @@ public class Client {
var serviceKey: String!

return firstly { () -> Promise<(data: Data, response: URLResponse)> in
self.session.dataTask(.promise, with: URLRequest.itcServiceKey)
Current.network.dataTask(with: URLRequest.itcServiceKey)
}
.then { (data, _) -> Promise<(data: Data, response: URLResponse)> in
struct ServiceKeyResponse: Decodable {
Expand All @@ -51,7 +57,7 @@ public class Client {
let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data)
serviceKey = response.authServiceKey

return self.session.dataTask(.promise, with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
}
.then { (data, response) -> Promise<Void> in
struct SignInResponse: Decodable {
Expand All @@ -73,11 +79,11 @@ public class Client {

switch httpResponse.statusCode {
case 200:
return self.session.dataTask(.promise, with: URLRequest.olympusSession).asVoid()
return Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
case 401:
throw Error.invalidUsernameOrPassword(username: accountName)
case 409:
return self.handleTwoFactor(data: data, response: response, serviceKey: serviceKey)
return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
throw Error.appleIDAndPrivacyAcknowledgementRequired
default:
Expand All @@ -87,24 +93,215 @@ public class Client {
}
}

public func handleTwoFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise<Void> {
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise<Void> {
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<(data: Data, response: URLResponse)> in
self.session.dataTask(.promise, with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
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 { (data, response) -> Promise<(data: Data, response: URLResponse)> in
print("Enter the code: ")
let code = readLine() ?? ""
return self.session.dataTask(.promise, with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
.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(())
}
}
.then { (data, response) -> Promise<(data: Data, response: URLResponse)> in
self.session.dataTask(.promise, with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
}

func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise<Void> {
Current.logging.log("Two-factor authentication is enabled for this account.\n")

// SMS was sent automatically
if authOptions.smsAutomaticallySent {
return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in
let code = self.promptForSMSSecurityCode(length: authOptions.securityCode.length, for: authOptions.trustedPhoneNumbers!.first!)
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)
}
// SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS {
return handleWithPhoneNumberSelection(authOptions: authOptions, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
// Code is shown on trusted devices
} else {
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 \(authOptions.securityCode.length) digit code from one of your trusted devices:
""") ?? ""

if code == "sms" {
return handleWithPhoneNumberSelection(authOptions: authOptions, 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)
}
}
.then { (data, response) -> Promise<Void> in
self.session.dataTask(.promise, with: URLRequest.olympusSession).asVoid()
}

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(authOptions: AuthOptionsResponse, 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 = authOptions.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: authOptions.securityCode.length, for: trustedPhoneNumber)
}
}
.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)
}
}
}

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

struct AuthOptionsResponse: Decodable {
let trustedPhoneNumbers: [TrustedPhoneNumber]?
let trustedDevices: [TrustedDevice]?
let securityCode: SecurityCodeInfo
let noTrustedDevices: Bool?
let serviceErrors: [ServiceError]?

var kind: Kind {
if trustedDevices != nil {
return .twoStep
} else if trustedPhoneNumbers != nil {
return .twoFactor
} else {
return .unknown
}
}

// One time with a new testing account I had a response where noTrustedDevices was nil, but the account didn't have any trusted devices.
// This should have been a situation where an SMS security code was sent automatically.
// This resolved itself either after some time passed, or by signing into appleid.apple.com with the account.
// Not sure if it's worth explicitly handling this case or if it'll be really rare.
var canFallBackToSMS: Bool {
noTrustedDevices == true
}

var smsAutomaticallySent: Bool {
trustedPhoneNumbers?.count == 1 && canFallBackToSMS
}

struct TrustedPhoneNumber: Decodable {
let id: Int
let numberWithDialCode: String
}

struct TrustedDevice: Decodable {
let id: String
let name: String
let modelName: String
}

struct SecurityCodeInfo: Decodable {
let length: Int
let tooManyCodesSent: Bool
let tooManyCodesValidated: Bool
let securityCodeLocked: Bool
let securityCodeCooldown: Bool
}

enum Kind {
case twoStep, twoFactor, unknown
}
}

public struct ServiceError: Decodable, Equatable {
let code: String
let message: String
}

enum SecurityCode {
case device(code: String)
case sms(code: String, phoneNumberId: Int)

var urlPathComponent: String {
switch self {
case .device: return "trusteddevice"
case .sms: return "phone"
}
}
}