Skip to content

Commit 483cf98

Browse files
janpioJosh Holtz
authored andcommitted
[spaceship] Automate phone number selection for "request code via SMS" in 2FA with SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER env var (fastlane#14436)
* implement SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER * tests for phone_id_from_number * unrelated comment change * make rubocop happy * fix code for older ruby versions * also work for country code with 3 digits * helpful output that surfaces SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER * set missing code type value when ENV var is present * also remove " from ENV var (which tends to be added by silly windows developer [like me!]) * [spaceauth] surface more information about exceptions * terrible first try at testing handle_two_factor * fix wrong env var (wtf how did this work before?) * clean up tests * maybe? * make rubocop happy * future tests * rubocop * check if ENV var SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER is empty only read ENV var once * rename variable * add link to future docs page and some empty lines for structure
1 parent 35bbda7 commit 483cf98

File tree

6 files changed

+240
-18
lines changed

6 files changed

+240
-18
lines changed

cert/lib/cert/runner.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def create_certificate
153153
# Create a new certificate signing request
154154
csr, pkey = Spaceship.certificate.create_certificate_signing_request
155155

156-
# Use the signing request to create a new distribution certificate
156+
# Use the signing request to create a new (development|distribution) certificate
157157
begin
158158
certificate = certificate_type.create!(csr: csr)
159159
rescue => ex

spaceship/lib/spaceship/spaceauth_runner.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ def run
1818
Spaceship::Tunes.login(@username)
1919
puts("Successfully logged in to App Store Connect".green)
2020
puts("")
21-
rescue
21+
rescue => ex
2222
puts("Could not login to App Store Connect".red)
2323
puts("Please check your credentials and try again.".yellow)
2424
puts("This could be an issue with App Store Connect,".yellow)
2525
puts("Please try unsetting the FASTLANE_SESSION environment variable".yellow)
2626
puts("(if it is set) and re-run `fastlane spaceauth`".yellow)
27-
raise "Problem connecting to App Store Connect"
27+
puts("")
28+
puts("Execption type: #{ex.class}")
29+
raise ex
2830
end
2931

3032
itc_cookie_content = Spaceship::Tunes.client.store_cookie

spaceship/lib/spaceship/two_step_or_factor_client.rb

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,33 @@ def handle_two_factor(response, depth = 0)
123123
code_length = security_code["length"]
124124

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

131-
if code == 'sms'
128+
if env_2fa_sms_default_phone_number
129+
raise Tunes::Error.new, "Environment variable SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER is set, but empty." if env_2fa_sms_default_phone_number.empty?
130+
131+
puts("Environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` is set, automatically requesting 2FA token via SMS to that number")
132+
puts("SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER = #{env_2fa_sms_default_phone_number}")
133+
puts("")
134+
phone_number = env_2fa_sms_default_phone_number
135+
phone_id = phone_id_from_number(response.body["trustedPhoneNumbers"], phone_number)
132136
code_type = 'phone'
133-
body = request_two_factor_code_from_phone(response.body["trustedPhoneNumbers"], code_length)
137+
body = request_two_factor_code_from_phone(phone_id, phone_number, code_length)
138+
else
139+
puts("(Input `sms` to escape this prompt and select a trusted phone number to send the code as a text message)")
140+
puts("")
141+
puts("(You can also set the environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` to automate this)")
142+
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)")
143+
puts("")
144+
code_type = 'trusteddevice'
145+
code = ask_for_2fa_code("Please enter the #{code_length} digit code:")
146+
body = { "securityCode" => { "code" => code.to_s } }.to_json
147+
148+
# User exited by entering `sms` and wants to choose phone number for SMS
149+
if code == 'sms'
150+
code_type = 'phone'
151+
body = request_two_factor_code_from_phone_choose(response.body["trustedPhoneNumbers"], code_length)
152+
end
134153
end
135154

136155
puts("Requesting session...")
@@ -172,23 +191,68 @@ def handle_two_factor(response, depth = 0)
172191
return true
173192
end
174193

175-
def get_id_for_number(phone_numbers, result)
194+
# extracted into its own method for testing
195+
def ask_for_2fa_code(text)
196+
ask(text)
197+
end
198+
199+
def phone_id_from_number(phone_numbers, phone_number)
200+
characters_to_remove_from_phone_numbers = ' \-()"'
201+
202+
# start with e.g. +49 162 1234585 or +1-123-456-7866
203+
phone_number = phone_number.tr(characters_to_remove_from_phone_numbers, '')
204+
# cleaned: +491621234585 or +11234567866
205+
206+
phone_numbers.each do |phone|
207+
# rubocop:disable Style/AsciiComments
208+
# start with: +49 •••• •••••85 or +1 (•••) •••-••66
209+
number_with_dialcode_masked = phone['numberWithDialCode'].tr(characters_to_remove_from_phone_numbers, '')
210+
# cleaned: +49•••••••••85 or +1••••••••66
211+
# rubocop:enable Style/AsciiComments
212+
213+
maskings_count = number_with_dialcode_masked.count('•') # => 9 or 8
214+
pattern = /^([0-9+]{2,4})([•]{#{maskings_count}})([0-9]{2})$/
215+
replacement = "\\1([0-9]{#{maskings_count - 1},#{maskings_count}})\\3"
216+
number_with_dialcode_regex_part = number_with_dialcode_masked.gsub(pattern, replacement)
217+
# => +49([0-9]{8,9})85 or +1([0-9]{7,8})66
218+
219+
backslash = '\\'
220+
number_with_dialcode_regex_part = backslash + number_with_dialcode_regex_part
221+
number_with_dialcode_regex = /^#{number_with_dialcode_regex_part}$/
222+
# => /^\+49([0-9]{8})85$/ or /^\+1([0-9]{7,8})66$/
223+
224+
return phone['id'] if phone_number =~ number_with_dialcode_regex
225+
# +491621234585 matches /^\+49([0-9]{8})85$/
226+
end
227+
228+
# Handle case of phone_number not existing in phone_numbers because ENV var is wrong or matcher is broken
229+
raise Tunes::Error.new, %(
230+
Could not find a matching phone number to #{phone_number} in #{phone_numbers}.
231+
Make sure your environment variable is set to the correct phone number.
232+
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.
233+
)
234+
end
235+
236+
def phone_id_from_masked_number(phone_numbers, masked_number)
176237
phone_numbers.each do |phone|
177-
phone_id = phone['id']
178-
return phone_id if phone['numberWithDialCode'] == result
238+
return phone['id'] if phone['numberWithDialCode'] == masked_number
179239
end
180240
end
181241

182-
def request_two_factor_code_from_phone(phone_numbers, code_length)
242+
def request_two_factor_code_from_phone_choose(phone_numbers, code_length)
183243
puts("Please select a trusted phone number to send code to:")
184244

185245
available = phone_numbers.collect do |current|
186246
current['numberWithDialCode']
187247
end
188-
result = choose(*available)
248+
chosen = choose(*available)
249+
phone_id = phone_id_from_masked_number(phone_numbers, chosen)
189250

190-
phone_id = get_id_for_number(phone_numbers, result)
251+
request_two_factor_code_from_phone(phone_id, chosen, code_length)
252+
end
191253

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

204-
puts("Successfully requested text message")
268+
puts("Successfully requested text message to #{phone_number}")
269+
270+
code = ask_for_2fa_code("Please enter the #{code_length} digit code you received at #{phone_number}:")
205271

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

210275
def store_session
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"trustedPhoneNumbers": [{
3+
"numberWithDialCode": "+49 •••• •••••85",
4+
"pushMode": "sms",
5+
"obfuscatedNumber": "•••• •••••85",
6+
"id": 1
7+
}, {
8+
"numberWithDialCode": "+49 ••••• •••••81",
9+
"pushMode": "sms",
10+
"obfuscatedNumber": "••••• •••••81",
11+
"id": 2
12+
}],
13+
"securityCode": {
14+
"length": 6,
15+
"tooManyCodesSent": false,
16+
"tooManyCodesValidated": false,
17+
"securityCodeLocked": false
18+
},
19+
"authenticationType": "hsa2",
20+
"recoveryUrl": "https://iforgot.apple.com/phone/add?prs_account_nm=email@example.org&autoSubmitAccount=true&appId=142",
21+
"cantUsePhoneNumberUrl": "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=email@example.org&autoSubmitAccount=true&appId=142",
22+
"recoveryWebUrl": "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=email@example.org&autoSubmitAccount=true&appId=142",
23+
"repairPhoneNumberUrl": "https://gsa.apple.com/appleid/account/manage/repair/verify/phone",
24+
"repairPhoneNumberWebUrl": "https://appleid.apple.com/widget/account/repair?#!repair",
25+
"aboutTwoFactorAuthenticationUrl": "https://support.apple.com/kb/HT204921",
26+
"autoVerified": false,
27+
"showAutoVerificationUI": false,
28+
"managedAccount": false,
29+
"trustedPhoneNumber": {
30+
"numberWithDialCode": "+49 •••• •••••85",
31+
"pushMode": "sms",
32+
"obfuscatedNumber": "•••• •••••85",
33+
"id": 1
34+
},
35+
"hsa2Account": true,
36+
"supportsRecovery": true
37+
}

spaceship/spec/tunes/tunes_stubbing.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ def itc_stub_login
4040
with(body: "{\"contentProviderId\":\"5678\",\"dsId\":null}",
4141
headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'application/json' }).
4242
to_return(status: 200, body: "", headers: {})
43+
44+
# 2FA: Request security code to trusted phone
45+
stub_request(:put, "https://idmsa.apple.com/appleauth/auth/verify/phone").
46+
with(body: "{\"phoneNumber\":{\"id\":1},\"mode\":\"sms\"}").
47+
to_return(status: 200, body: "", headers: {})
48+
49+
# 2FA: Submit security code from trusted phone for verification
50+
stub_request(:post, "https://idmsa.apple.com/appleauth/auth/verify/phone/securitycode").
51+
with(body: "{\"securityCode\":{\"code\":\"123\"},\"phoneNumber\":{\"id\":1},\"mode\":\"sms\"}").
52+
to_return(status: 200, body: "", headers: {})
53+
54+
# 2FA: Trust computer
55+
stub_request(:get, "https://idmsa.apple.com/appleauth/auth/2sv/trust").
56+
to_return(status: 200, body: "", headers: {})
4357
end
4458

4559
def itc_stub_applications
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
require_relative 'mock_servers'
2+
3+
describe Spaceship::Client do
4+
class TwoStepOrFactorClient < Spaceship::Client
5+
def self.hostname
6+
"http://example.com"
7+
end
8+
9+
def ask_for_2fa_code(text)
10+
'123'
11+
end
12+
13+
def store_cookie(path: nil)
14+
true
15+
end
16+
17+
# these tests actually "send requests" - and `update_request_headers` would otherwise
18+
# add data to the headers that does not exist / is empty which will crash faraday later
19+
def update_request_headers(req)
20+
req
21+
end
22+
end
23+
24+
let(:subject) { TwoStepOrFactorClient.new }
25+
26+
let(:phone_numbers_json_string) do
27+
'
28+
[
29+
{ "id" : 1, "numberWithDialCode" : "+49 •••• •••••85", "obfuscatedNumber" : "•••• •••••85", "pushMode" : "sms" },
30+
{ "id" : 2, "numberWithDialCode" : "+49 ••••• •••••81", "obfuscatedNumber" : "••••• •••••81", "pushMode" : "sms" },
31+
{ "id" : 3, "numberWithDialCode" : "+1 (•••) •••-••66", "obfuscatedNumber" : "(•••) •••-••66", "pushMode" : "sms" },
32+
{ "id" : 4, "numberWithDialCode" : "+39 ••• ••• ••71", "obfuscatedNumber" : "••• ••• ••71", "pushMode" : "sms" },
33+
{ "id" : 5, "numberWithDialCode" : "+353 •• ••• ••43", "obfuscatedNumber" : "••• ••• •43", "pushMode" : "sms" }
34+
]
35+
'
36+
end
37+
let(:phone_numbers) { JSON.parse(phone_numbers_json_string) }
38+
39+
describe 'phone_id_from_number' do
40+
{
41+
"+49 123 4567885" => 1,
42+
"+4912341234581" => 2,
43+
"+1-123-456-7866" => 3,
44+
"+39 123 456 7871" => 4,
45+
"+353123456743" => 5
46+
}.each do |number_to_test, expected_phone_id|
47+
it "selects correct phone id #{expected_phone_id} for provided phone number #{number_to_test}" do
48+
phone_id = subject.phone_id_from_number(phone_numbers, number_to_test)
49+
expect(phone_id).to eq(expected_phone_id)
50+
end
51+
end
52+
53+
it "raises an error with unknown phone number" do
54+
phone_number = 'la le lu'
55+
expect do
56+
phone_id = subject.phone_id_from_number(phone_numbers, phone_number)
57+
end.to raise_error(Spaceship::Tunes::Error)
58+
end
59+
end
60+
61+
describe 'handle_two_factor' do
62+
let(:response_fixture) { File.read(File.join('spaceship', 'spec', 'fixtures', 'client_appleauth_auth_2fa_response.json'), encoding: 'utf-8') }
63+
let(:response) { OpenStruct.new }
64+
before do
65+
response.body = JSON.parse(response_fixture)
66+
end
67+
68+
describe 'with SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER set' do
69+
after do
70+
ENV.delete('SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER')
71+
end
72+
73+
it 'to a known phone number returns true (and sends the correct requests)' do
74+
phone_number = '+49 123 4567885'
75+
ENV['SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER'] = phone_number
76+
77+
response = OpenStruct.new
78+
response.body = JSON.parse(response_fixture)
79+
bool = subject.handle_two_factor(response)
80+
81+
expect(bool).to eq(true)
82+
83+
# expected requests
84+
expect(WebMock).to have_requested(:put, 'https://idmsa.apple.com/appleauth/auth/verify/phone').with(body: { phoneNumber: { id: 1 }, mode: "sms" })
85+
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" })
86+
expect(WebMock).to have_requested(:get, 'https://idmsa.apple.com/appleauth/auth/2sv/trust')
87+
end
88+
89+
it 'to a unknown phone number throws an exception' do
90+
phone_number = '+49 123 4567800'
91+
ENV['SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER'] = phone_number
92+
93+
expect do
94+
bool = subject.handle_two_factor(response)
95+
end.to raise_error(Spaceship::Tunes::Error)
96+
end
97+
end
98+
99+
describe 'with SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER not set' do
100+
# 1. input of pushed code
101+
# 2. input of `sms`, then selection of phone, then input of sms-ed code
102+
end
103+
end
104+
end

0 commit comments

Comments
 (0)