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

[spaceship] Automate phone number selection for "request code via SMS" in 2FA with SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER env var #14436

Merged
merged 20 commits into from Apr 19, 2019
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
2 changes: 1 addition & 1 deletion cert/lib/cert/runner.rb
Expand Up @@ -153,7 +153,7 @@ def create_certificate
# Create a new certificate signing request
csr, pkey = Spaceship.certificate.create_certificate_signing_request

# Use the signing request to create a new distribution certificate
# Use the signing request to create a new (development|distribution) certificate
begin
certificate = certificate_type.create!(csr: csr)
rescue => ex
Expand Down
6 changes: 4 additions & 2 deletions spaceship/lib/spaceship/spaceauth_runner.rb
Expand Up @@ -18,13 +18,15 @@ def run
Spaceship::Tunes.login(@username)
puts("Successfully logged in to App Store Connect".green)
puts("")
rescue
rescue => ex
puts("Could not login to App Store Connect".red)
puts("Please check your credentials and try again.".yellow)
puts("This could be an issue with App Store Connect,".yellow)
puts("Please try unsetting the FASTLANE_SESSION environment variable".yellow)
puts("(if it is set) and re-run `fastlane spaceauth`".yellow)
raise "Problem connecting to App Store Connect"
puts("")
puts("Execption type: #{ex.class}")
raise ex
end

itc_cookie_content = Spaceship::Tunes.client.store_cookie
Expand Down
95 changes: 80 additions & 15 deletions spaceship/lib/spaceship/two_step_or_factor_client.rb
Expand Up @@ -123,14 +123,33 @@ def handle_two_factor(response, depth = 0)
code_length = security_code["length"]

puts("")
puts("(Input `sms` to escape this prompt and select a trusted phone number to send the code as a text message)")
code_type = 'trusteddevice'
code = ask("Please enter the #{code_length} digit code:")
body = { "securityCode" => { "code" => code.to_s } }.to_json
env_2fa_sms_default_phone_number = ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"]

if code == 'sms'
if env_2fa_sms_default_phone_number
raise Tunes::Error.new, "Environment variable SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER is set, but empty." if env_2fa_sms_default_phone_number.empty?

puts("Environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` is set, automatically requesting 2FA token via SMS to that number")
puts("SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER = #{env_2fa_sms_default_phone_number}")
puts("")
phone_number = env_2fa_sms_default_phone_number
phone_id = phone_id_from_number(response.body["trustedPhoneNumbers"], phone_number)
code_type = 'phone'
body = request_two_factor_code_from_phone(response.body["trustedPhoneNumbers"], code_length)
body = request_two_factor_code_from_phone(phone_id, phone_number, code_length)
else
puts("(Input `sms` to escape this prompt and select a trusted phone number to send the code as a text message)")
puts("")
puts("(You can also set the environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` to automate this)")
janpio marked this conversation as resolved.
Show resolved Hide resolved
puts("(Read more at: https://github.com/fastlane/fastlane/blob/master/spaceship/docs/Authentication.md#auto-select-sms-via-spaceship-2fa-sms-default-phone-number)")
puts("")
code_type = 'trusteddevice'
code = ask_for_2fa_code("Please enter the #{code_length} digit code:")
body = { "securityCode" => { "code" => code.to_s } }.to_json

# User exited by entering `sms` and wants to choose phone number for SMS
if code == 'sms'
code_type = 'phone'
body = request_two_factor_code_from_phone_choose(response.body["trustedPhoneNumbers"], code_length)
end
end

puts("Requesting session...")
Expand Down Expand Up @@ -172,23 +191,68 @@ def handle_two_factor(response, depth = 0)
return true
end

def get_id_for_number(phone_numbers, result)
# extracted into its own method for testing
def ask_for_2fa_code(text)
ask(text)
end

def phone_id_from_number(phone_numbers, phone_number)
characters_to_remove_from_phone_numbers = ' \-()"'

# start with e.g. +49 162 1234585 or +1-123-456-7866
phone_number = phone_number.tr(characters_to_remove_from_phone_numbers, '')
# cleaned: +491621234585 or +11234567866

phone_numbers.each do |phone|
# rubocop:disable Style/AsciiComments
# start with: +49 •••• •••••85 or +1 (•••) •••-••66
number_with_dialcode_masked = phone['numberWithDialCode'].tr(characters_to_remove_from_phone_numbers, '')
# cleaned: +49•••••••••85 or +1••••••••66
# rubocop:enable Style/AsciiComments

maskings_count = number_with_dialcode_masked.count('•') # => 9 or 8
pattern = /^([0-9+]{2,4})([•]{#{maskings_count}})([0-9]{2})$/
replacement = "\\1([0-9]{#{maskings_count - 1},#{maskings_count}})\\3"
number_with_dialcode_regex_part = number_with_dialcode_masked.gsub(pattern, replacement)
# => +49([0-9]{8,9})85 or +1([0-9]{7,8})66

backslash = '\\'
number_with_dialcode_regex_part = backslash + number_with_dialcode_regex_part
number_with_dialcode_regex = /^#{number_with_dialcode_regex_part}$/
# => /^\+49([0-9]{8})85$/ or /^\+1([0-9]{7,8})66$/

return phone['id'] if phone_number =~ number_with_dialcode_regex
# +491621234585 matches /^\+49([0-9]{8})85$/
end

# Handle case of phone_number not existing in phone_numbers because ENV var is wrong or matcher is broken
raise Tunes::Error.new, %(
Could not find a matching phone number to #{phone_number} in #{phone_numbers}.
janpio marked this conversation as resolved.
Show resolved Hide resolved
Make sure your environment variable is set to the correct phone number.
If it is, please open an issue at https://github.com/fastlane/fastlane/issues/new and include this output so we can fix our matcher. Thanks.
)
end

def phone_id_from_masked_number(phone_numbers, masked_number)
phone_numbers.each do |phone|
phone_id = phone['id']
return phone_id if phone['numberWithDialCode'] == result
return phone['id'] if phone['numberWithDialCode'] == masked_number
end
end

def request_two_factor_code_from_phone(phone_numbers, code_length)
def request_two_factor_code_from_phone_choose(phone_numbers, code_length)
puts("Please select a trusted phone number to send code to:")

available = phone_numbers.collect do |current|
current['numberWithDialCode']
end
result = choose(*available)
chosen = choose(*available)
phone_id = phone_id_from_masked_number(phone_numbers, chosen)

phone_id = get_id_for_number(phone_numbers, result)
request_two_factor_code_from_phone(phone_id, chosen, code_length)
end

# this is used in two places: after choosing a phone number and when a phone number is set via ENV var
def request_two_factor_code_from_phone(phone_id, phone_number, code_length)
# Request code
r = request(:put) do |req|
req.url("https://idmsa.apple.com/appleauth/auth/verify/phone")
Expand All @@ -201,10 +265,11 @@ def request_two_factor_code_from_phone(phone_numbers, code_length)
# since this might be from the Dev Portal, but for 2 step
Spaceship::TunesClient.new.handle_itc_response(r.body)

puts("Successfully requested text message")
puts("Successfully requested text message to #{phone_number}")

code = ask_for_2fa_code("Please enter the #{code_length} digit code you received at #{phone_number}:")

code = ask("Please enter the #{code_length} digit code you received at #{result}:")
{ "securityCode" => { "code" => code.to_s }, "phoneNumber" => { "id" => phone_id }, "mode" => "sms" }.to_json
return { "securityCode" => { "code" => code.to_s }, "phoneNumber" => { "id" => phone_id }, "mode" => "sms" }.to_json
end

def store_session
Expand Down
37 changes: 37 additions & 0 deletions spaceship/spec/fixtures/client_appleauth_auth_2fa_response.json
@@ -0,0 +1,37 @@
{
"trustedPhoneNumbers": [{
"numberWithDialCode": "+49 •••• •••••85",
"pushMode": "sms",
"obfuscatedNumber": "•••• •••••85",
"id": 1
}, {
"numberWithDialCode": "+49 ••••• •••••81",
"pushMode": "sms",
"obfuscatedNumber": "••••• •••••81",
"id": 2
}],
"securityCode": {
"length": 6,
"tooManyCodesSent": false,
"tooManyCodesValidated": false,
"securityCodeLocked": false
},
"authenticationType": "hsa2",
"recoveryUrl": "https://iforgot.apple.com/phone/add?prs_account_nm=email@example.org&autoSubmitAccount=true&appId=142",
"cantUsePhoneNumberUrl": "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=email@example.org&autoSubmitAccount=true&appId=142",
"recoveryWebUrl": "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=email@example.org&autoSubmitAccount=true&appId=142",
"repairPhoneNumberUrl": "https://gsa.apple.com/appleid/account/manage/repair/verify/phone",
"repairPhoneNumberWebUrl": "https://appleid.apple.com/widget/account/repair?#!repair",
"aboutTwoFactorAuthenticationUrl": "https://support.apple.com/kb/HT204921",
"autoVerified": false,
"showAutoVerificationUI": false,
"managedAccount": false,
"trustedPhoneNumber": {
"numberWithDialCode": "+49 •••• •••••85",
"pushMode": "sms",
"obfuscatedNumber": "•••• •••••85",
"id": 1
},
"hsa2Account": true,
"supportsRecovery": true
}
14 changes: 14 additions & 0 deletions spaceship/spec/tunes/tunes_stubbing.rb
Expand Up @@ -40,6 +40,20 @@ def itc_stub_login
with(body: "{\"contentProviderId\":\"5678\",\"dsId\":null}",
headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'application/json' }).
to_return(status: 200, body: "", headers: {})

# 2FA: Request security code to trusted phone
stub_request(:put, "https://idmsa.apple.com/appleauth/auth/verify/phone").
with(body: "{\"phoneNumber\":{\"id\":1},\"mode\":\"sms\"}").
to_return(status: 200, body: "", headers: {})

# 2FA: Submit security code from trusted phone for verification
stub_request(:post, "https://idmsa.apple.com/appleauth/auth/verify/phone/securitycode").
with(body: "{\"securityCode\":{\"code\":\"123\"},\"phoneNumber\":{\"id\":1},\"mode\":\"sms\"}").
to_return(status: 200, body: "", headers: {})

# 2FA: Trust computer
stub_request(:get, "https://idmsa.apple.com/appleauth/auth/2sv/trust").
to_return(status: 200, body: "", headers: {})
end

def itc_stub_applications
Expand Down
104 changes: 104 additions & 0 deletions spaceship/spec/two_step_or_factor_client_spec.rb
@@ -0,0 +1,104 @@
require_relative 'mock_servers'

describe Spaceship::Client do
class TwoStepOrFactorClient < Spaceship::Client
def self.hostname
"http://example.com"
end

def ask_for_2fa_code(text)
'123'
end

def store_cookie(path: nil)
true
end

# these tests actually "send requests" - and `update_request_headers` would otherwise
# add data to the headers that does not exist / is empty which will crash faraday later
def update_request_headers(req)
req
end
end

let(:subject) { TwoStepOrFactorClient.new }

let(:phone_numbers_json_string) do
'
[
{ "id" : 1, "numberWithDialCode" : "+49 •••• •••••85", "obfuscatedNumber" : "•••• •••••85", "pushMode" : "sms" },
{ "id" : 2, "numberWithDialCode" : "+49 ••••• •••••81", "obfuscatedNumber" : "••••• •••••81", "pushMode" : "sms" },
{ "id" : 3, "numberWithDialCode" : "+1 (•••) •••-••66", "obfuscatedNumber" : "(•••) •••-••66", "pushMode" : "sms" },
{ "id" : 4, "numberWithDialCode" : "+39 ••• ••• ••71", "obfuscatedNumber" : "••• ••• ••71", "pushMode" : "sms" },
{ "id" : 5, "numberWithDialCode" : "+353 •• ••• ••43", "obfuscatedNumber" : "••• ••• •43", "pushMode" : "sms" }
]
'
end
let(:phone_numbers) { JSON.parse(phone_numbers_json_string) }

describe 'phone_id_from_number' do
{
"+49 123 4567885" => 1,
"+4912341234581" => 2,
"+1-123-456-7866" => 3,
"+39 123 456 7871" => 4,
"+353123456743" => 5
}.each do |number_to_test, expected_phone_id|
it "selects correct phone id #{expected_phone_id} for provided phone number #{number_to_test}" do
phone_id = subject.phone_id_from_number(phone_numbers, number_to_test)
expect(phone_id).to eq(expected_phone_id)
end
end

it "raises an error with unknown phone number" do
phone_number = 'la le lu'
expect do
phone_id = subject.phone_id_from_number(phone_numbers, phone_number)
end.to raise_error(Spaceship::Tunes::Error)
end
end

describe 'handle_two_factor' do
let(:response_fixture) { File.read(File.join('spaceship', 'spec', 'fixtures', 'client_appleauth_auth_2fa_response.json'), encoding: 'utf-8') }
let(:response) { OpenStruct.new }
before do
response.body = JSON.parse(response_fixture)
end

describe 'with SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER set' do
after do
ENV.delete('SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER')
end

it 'to a known phone number returns true (and sends the correct requests)' do
phone_number = '+49 123 4567885'
ENV['SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER'] = phone_number

response = OpenStruct.new
response.body = JSON.parse(response_fixture)
bool = subject.handle_two_factor(response)

expect(bool).to eq(true)

# expected requests
expect(WebMock).to have_requested(:put, 'https://idmsa.apple.com/appleauth/auth/verify/phone').with(body: { phoneNumber: { id: 1 }, mode: "sms" })
expect(WebMock).to have_requested(:post, 'https://idmsa.apple.com/appleauth/auth/verify/phone/securitycode').with(body: { securityCode: { code: "123" }, phoneNumber: { id: 1 }, mode: "sms" })
expect(WebMock).to have_requested(:get, 'https://idmsa.apple.com/appleauth/auth/2sv/trust')
end

it 'to a unknown phone number throws an exception' do
phone_number = '+49 123 4567800'
ENV['SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER'] = phone_number

expect do
bool = subject.handle_two_factor(response)
end.to raise_error(Spaceship::Tunes::Error)
end
end

describe 'with SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER not set' do
# 1. input of pushed code
# 2. input of `sms`, then selection of phone, then input of sms-ed code
end
end
end