git-fit 0.10.10 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/git_fit/auth/garmin/login_session.rb +53 -0
- data/lib/git_fit/auth/garmin/strategy_a.rb +64 -18
- data/lib/git_fit/auth/garmin/strategy_b.rb +57 -13
- data/lib/git_fit/auth/garmin.rb +23 -9
- data/lib/git_fit/cli.rb +2 -2
- data/lib/git_fit/config_template.rb +2 -0
- data/lib/git_fit/sync/xingzhe.rb +90 -8
- data/lib/git_fit/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49c616974f43c0eef2a07460e664450cffb887629d63b0e7d9f64e9da73dec09
|
|
4
|
+
data.tar.gz: 8924a27a358440f1f1a2bd1b233d6a11589d5aca0eb9c68be63bd31679e5e9af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42ea6e2aaef24cfb9f3aecf136ac365542bca0ce9824ee0bbba2b1e970c28a1f7a3db201be81cd3717a3bc85283c89274051e6ecef7d83df89105599e83a588b
|
|
7
|
+
data.tar.gz: 30f1c4f88ac926e4581159e7a76ed92e0fc88ef764e9145d5901efacbab53baf387bbe7aad2864afdd46ecaa5d548043b1cd9e61ed2ed8009bf169d9d70145f8
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../sync/garmin_base'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
module Auth
|
|
9
|
+
module Garmin
|
|
10
|
+
# Raised when MFA is required and no code is available. The login session
|
|
11
|
+
# (pre-auth cookie jar / storage_state) has already been persisted so the
|
|
12
|
+
# user can resume with `--mfa-code` without re-triggering MFA.
|
|
13
|
+
class MfaRequired < GitFit::Sync::AuthError; end
|
|
14
|
+
|
|
15
|
+
# Persists the pre-auth login session created during an interrupted MFA
|
|
16
|
+
# flow. This is a *temporary* session (30-minute TTL matching the MFA code
|
|
17
|
+
# validity window), distinct from the long-lived persistent session cookie
|
|
18
|
+
# (garmin_playwright_session_*.json) that Strategy B keeps after success.
|
|
19
|
+
module LoginSession
|
|
20
|
+
DIR = 'data/cache'
|
|
21
|
+
TTL_SECONDS = 30 * 60
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def save(domain, state)
|
|
26
|
+
FileUtils.mkdir_p(DIR)
|
|
27
|
+
state['saved_at'] = Time.now.to_f
|
|
28
|
+
File.write(path(domain), JSON.generate(state))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load(domain)
|
|
32
|
+
p = path(domain)
|
|
33
|
+
return nil unless File.exist?(p)
|
|
34
|
+
|
|
35
|
+
state = JSON.parse(File.read(p))
|
|
36
|
+
return nil if (Time.now.to_f - state['saved_at'].to_f) > TTL_SECONDS
|
|
37
|
+
|
|
38
|
+
state
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear(domain)
|
|
44
|
+
File.delete(path(domain)) if File.exist?(path(domain))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def path(domain)
|
|
48
|
+
File.join(DIR, "garmin_mfa_login_#{domain.tr('.', '_')}.json")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'di_exchange'
|
|
4
|
+
require_relative 'login_session'
|
|
4
5
|
require_relative '../../sync/garmin_base'
|
|
5
6
|
require 'open3'
|
|
6
7
|
require 'tmpdir'
|
|
@@ -43,9 +44,16 @@ module GitFit
|
|
|
43
44
|
def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
|
|
44
45
|
check_curl_available!
|
|
45
46
|
|
|
47
|
+
code = mfa_code.to_s.strip
|
|
48
|
+
if !code.empty? && (saved = LoginSession.load(domain))
|
|
49
|
+
return resume_login(domain, code, saved)
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
errors = []
|
|
47
53
|
begin
|
|
48
54
|
return finish(mobile_login(domain, email, password, mfa_code), domain)
|
|
55
|
+
rescue MfaRequired
|
|
56
|
+
raise
|
|
49
57
|
rescue GitFit::Sync::AuthError => e
|
|
50
58
|
errors << "mobile+cffi: #{e.message}"
|
|
51
59
|
warn "[garmin_auth] #{errors.last}"
|
|
@@ -56,6 +64,8 @@ module GitFit
|
|
|
56
64
|
|
|
57
65
|
begin
|
|
58
66
|
return finish(portal_login(domain, email, password, mfa_code), domain)
|
|
67
|
+
rescue MfaRequired
|
|
68
|
+
raise
|
|
59
69
|
rescue GitFit::Sync::AuthError => e
|
|
60
70
|
errors << "portal+cffi: #{e.message}"
|
|
61
71
|
warn "[garmin_auth] #{errors.last}"
|
|
@@ -76,11 +86,11 @@ module GitFit
|
|
|
76
86
|
def mobile_login(domain, email, password, mfa_code)
|
|
77
87
|
sso = "https://sso.#{domain}"
|
|
78
88
|
with_session_files do |jar, body|
|
|
79
|
-
mobile_login_session(sso, email, password, mfa_code, jar, body)
|
|
89
|
+
mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
|
|
80
90
|
end
|
|
81
91
|
end
|
|
82
92
|
|
|
83
|
-
def mobile_login_session(sso, email, password, mfa_code, jar, body)
|
|
93
|
+
def mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
|
|
84
94
|
random_delay('mobile+cffi')
|
|
85
95
|
signin_url = "#{sso}/mobile/sso/en_US/sign-in"
|
|
86
96
|
get_params = { clientId: MOBILE_CLIENT_ID, service: MOBILE_SERVICE }
|
|
@@ -130,7 +140,8 @@ module GitFit
|
|
|
130
140
|
if resp_type == 'MFA_REQUIRED'
|
|
131
141
|
method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
|
|
132
142
|
res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
133
|
-
impersonate: MOBILE_FINGERPRINT, jar: jar, output: body,
|
|
143
|
+
impersonate: MOBILE_FINGERPRINT, jar: jar, output: body,
|
|
144
|
+
email: email, domain: domain, service_url: MOBILE_SERVICE)
|
|
134
145
|
resp_type = res.dig('responseStatus', 'type')
|
|
135
146
|
end
|
|
136
147
|
|
|
@@ -146,19 +157,19 @@ module GitFit
|
|
|
146
157
|
def portal_login(domain, email, password, mfa_code)
|
|
147
158
|
sso = "https://sso.#{domain}"
|
|
148
159
|
TLS_FINGERPRINTS.each do |imp|
|
|
149
|
-
result = attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
|
|
160
|
+
result = attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
|
|
150
161
|
return result if result
|
|
151
162
|
end
|
|
152
163
|
raise GitFit::Sync::AuthError, 'portal+cffi: all TLS fingerprints exhausted'
|
|
153
164
|
end
|
|
154
165
|
|
|
155
|
-
def attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
|
|
166
|
+
def attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
|
|
156
167
|
with_session_files do |jar, body|
|
|
157
|
-
attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
|
|
168
|
+
attempt_portal_in_session(sso, domain, imp, email, password, mfa_code, jar, body)
|
|
158
169
|
end
|
|
159
170
|
end
|
|
160
171
|
|
|
161
|
-
def attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
|
|
172
|
+
def attempt_portal_in_session(sso, domain, imp, email, password, mfa_code, jar, body)
|
|
162
173
|
random_delay("portal+cffi/#{imp}")
|
|
163
174
|
signin_url = "#{sso}/portal/sso/en-US/sign-in"
|
|
164
175
|
get_params = { clientId: PORTAL_CLIENT_ID, service: PORTAL_SERVICE }
|
|
@@ -216,7 +227,8 @@ module GitFit
|
|
|
216
227
|
if resp_type == 'MFA_REQUIRED'
|
|
217
228
|
method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
|
|
218
229
|
res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
219
|
-
impersonate: imp, jar: jar, output: body,
|
|
230
|
+
impersonate: imp, jar: jar, output: body,
|
|
231
|
+
email: email, domain: domain, service_url: PORTAL_SERVICE)
|
|
220
232
|
resp_type = res.dig('responseStatus', 'type')
|
|
221
233
|
end
|
|
222
234
|
|
|
@@ -231,8 +243,17 @@ module GitFit
|
|
|
231
243
|
nil
|
|
232
244
|
end
|
|
233
245
|
|
|
234
|
-
def handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
246
|
+
def handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
247
|
+
impersonate:, jar:, output:, email:, domain:, service_url:)
|
|
235
248
|
code = resolve_mfa_code(mfa_code, email)
|
|
249
|
+
if code.nil?
|
|
250
|
+
save_login_session(domain, sso, method, login_params, post_headers, impersonate, jar, service_url)
|
|
251
|
+
raise MfaRequired, mfa_required_message(email)
|
|
252
|
+
end
|
|
253
|
+
verify_mfa(sso, method, login_params, post_headers, code, impersonate:, jar:, output:)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def verify_mfa(sso, method, login_params, post_headers, code, impersonate:, jar:, output:)
|
|
236
257
|
mfa_data = {
|
|
237
258
|
'mfaMethod' => method,
|
|
238
259
|
'mfaVerificationCode' => code,
|
|
@@ -267,6 +288,30 @@ module GitFit
|
|
|
267
288
|
raise GitFit::Sync::AuthError, 'MFA verification failed on all endpoints'
|
|
268
289
|
end
|
|
269
290
|
|
|
291
|
+
def save_login_session(domain, sso, method, login_params, post_headers, impersonate, jar, service_url)
|
|
292
|
+
LoginSession.save(domain, {
|
|
293
|
+
'strategy' => 'A',
|
|
294
|
+
'sso' => sso,
|
|
295
|
+
'method' => method,
|
|
296
|
+
'login_params' => login_params,
|
|
297
|
+
'post_headers' => post_headers,
|
|
298
|
+
'impersonate' => impersonate,
|
|
299
|
+
'jar' => File.read(jar),
|
|
300
|
+
'service_url' => service_url,
|
|
301
|
+
})
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def resume_login(domain, mfa_code, saved)
|
|
305
|
+
with_session_files do |jar, body|
|
|
306
|
+
File.write(jar, saved['jar'])
|
|
307
|
+
res = verify_mfa(saved['sso'], saved['method'], saved['login_params'],
|
|
308
|
+
saved['post_headers'], mfa_code,
|
|
309
|
+
impersonate: saved['impersonate'], jar: jar, output: body)
|
|
310
|
+
LoginSession.clear(domain)
|
|
311
|
+
finish([res['serviceTicketId'], saved['service_url']], domain)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
270
315
|
def resolve_mfa_code(mfa_code, email)
|
|
271
316
|
code = mfa_code.to_s.strip
|
|
272
317
|
if code.empty?
|
|
@@ -274,15 +319,16 @@ module GitFit
|
|
|
274
319
|
$stderr.write('Enter the 6-digit code: ') if $stdin.tty?
|
|
275
320
|
code = $stdin.gets.to_s.strip
|
|
276
321
|
end
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
code.empty? ? nil : code
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def mfa_required_message(email)
|
|
326
|
+
"MFA required — check your email (#{email}) for the code.\n" \
|
|
327
|
+
" This code is valid for a few minutes.\n\n" \
|
|
328
|
+
" To continue, choose one:\n" \
|
|
329
|
+
" echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
|
|
330
|
+
" git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
|
|
331
|
+
" If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
|
|
286
332
|
end
|
|
287
333
|
|
|
288
334
|
def check_curl_available!
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'di_exchange'
|
|
4
|
+
require_relative 'login_session'
|
|
4
5
|
require_relative '../../sync/garmin_base'
|
|
5
6
|
require 'json'
|
|
6
7
|
require 'fileutils'
|
|
@@ -29,6 +30,10 @@ module GitFit
|
|
|
29
30
|
session_path = _session_file_path(domain)
|
|
30
31
|
chrome_path = _find_chrome
|
|
31
32
|
|
|
33
|
+
if !mfa_code.empty? && (saved = LoginSession.load(domain))
|
|
34
|
+
return resume_login(domain, mfa_code, saved, chrome_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
32
37
|
_launch_playwright(chrome_path) do |browser|
|
|
33
38
|
session = _load_session(session_path)
|
|
34
39
|
warn "loaded session cookies (#{session[:age_days].round} days old)" if session
|
|
@@ -38,7 +43,7 @@ module GitFit
|
|
|
38
43
|
context = browser.new_context(**opts)
|
|
39
44
|
|
|
40
45
|
begin
|
|
41
|
-
ticket, service_url = _mobile_api_login(context
|
|
46
|
+
ticket, service_url = _mobile_api_login(context, domain, email, password, mfa_code, sso)
|
|
42
47
|
token = DIExchange.call(domain, ticket, service_url)
|
|
43
48
|
_save_session(context, session_path)
|
|
44
49
|
token
|
|
@@ -48,7 +53,23 @@ module GitFit
|
|
|
48
53
|
end
|
|
49
54
|
end
|
|
50
55
|
|
|
51
|
-
def
|
|
56
|
+
def resume_login(domain, mfa_code, saved, chrome_path)
|
|
57
|
+
_launch_playwright(chrome_path) do |browser|
|
|
58
|
+
context = browser.new_context(storageState: saved['storage_state'])
|
|
59
|
+
begin
|
|
60
|
+
ticket, = verify_mfa(context, saved['sso'], saved['method'], mfa_code)
|
|
61
|
+
token = DIExchange.call(domain, ticket, MOBILE_SERVICE)
|
|
62
|
+
_save_session(context, _session_file_path(domain))
|
|
63
|
+
LoginSession.clear(domain)
|
|
64
|
+
token
|
|
65
|
+
ensure
|
|
66
|
+
context.close
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def _mobile_api_login(context, domain, email, password, mfa_code, sso)
|
|
72
|
+
req = context.request
|
|
52
73
|
headers = {
|
|
53
74
|
'User-Agent' => MOBILE_UA,
|
|
54
75
|
'Accept' => 'application/json, text/plain, */*',
|
|
@@ -59,7 +80,7 @@ module GitFit
|
|
|
59
80
|
params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
|
|
60
81
|
referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
|
|
61
82
|
|
|
62
|
-
signin_resp =
|
|
83
|
+
signin_resp = req.get(
|
|
63
84
|
"#{sso}/mobile/sso/en_US/sign-in",
|
|
64
85
|
params: { 'clientId' => MOBILE_CLIENT_ID, 'service' => MOBILE_SERVICE },
|
|
65
86
|
headers: { 'User-Agent' => MOBILE_UA, 'Accept' => 'text/html,...', 'Accept-Language' => 'en-US,en;q=0.9' },
|
|
@@ -78,7 +99,7 @@ module GitFit
|
|
|
78
99
|
|
|
79
100
|
_random_delay('mobile')
|
|
80
101
|
warn 'posting login credentials...'
|
|
81
|
-
resp =
|
|
102
|
+
resp = req.post(
|
|
82
103
|
"#{sso}/mobile/api/login",
|
|
83
104
|
params: params,
|
|
84
105
|
headers: headers.merge('Referer' => referer),
|
|
@@ -103,8 +124,30 @@ module GitFit
|
|
|
103
124
|
|
|
104
125
|
mfa_method = data.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
|
|
105
126
|
code = resolve_mfa_code(email, mfa_code)
|
|
127
|
+
if code.nil?
|
|
128
|
+
LoginSession.save(domain, {
|
|
129
|
+
'strategy' => 'B',
|
|
130
|
+
'sso' => sso,
|
|
131
|
+
'method' => mfa_method,
|
|
132
|
+
'storage_state' => context.storage_state,
|
|
133
|
+
})
|
|
134
|
+
raise MfaRequired, mfa_required_message(email)
|
|
135
|
+
end
|
|
106
136
|
|
|
137
|
+
verify_mfa(context, sso, mfa_method, code)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def verify_mfa(context, sso, mfa_method, code)
|
|
107
141
|
warn 'verifying MFA code...'
|
|
142
|
+
headers = {
|
|
143
|
+
'User-Agent' => MOBILE_UA,
|
|
144
|
+
'Accept' => 'application/json, text/plain, */*',
|
|
145
|
+
'Content-Type' => 'application/json',
|
|
146
|
+
'Origin' => sso,
|
|
147
|
+
'Accept-Language' => 'en-US,en;q=0.9',
|
|
148
|
+
}
|
|
149
|
+
params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
|
|
150
|
+
referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
|
|
108
151
|
mfa_data = {
|
|
109
152
|
'mfaMethod' => mfa_method,
|
|
110
153
|
'mfaVerificationCode' => code,
|
|
@@ -113,7 +156,7 @@ module GitFit
|
|
|
113
156
|
'mfaSetup' => false,
|
|
114
157
|
}
|
|
115
158
|
|
|
116
|
-
resp = context.post(
|
|
159
|
+
resp = context.request.post(
|
|
117
160
|
"#{sso}/mobile/api/mfa/verifyCode",
|
|
118
161
|
params: params,
|
|
119
162
|
headers: headers.merge('Referer' => referer),
|
|
@@ -143,15 +186,16 @@ module GitFit
|
|
|
143
186
|
$stderr.write('Enter the 6-digit code: ') if $stdin.tty?
|
|
144
187
|
code = $stdin.gets.to_s.strip
|
|
145
188
|
end
|
|
146
|
-
|
|
189
|
+
code.empty? ? nil : code
|
|
190
|
+
end
|
|
147
191
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
192
|
+
def mfa_required_message(email)
|
|
193
|
+
"MFA required — check your email (#{email}) for the code.\n" \
|
|
194
|
+
" This code is valid for a few minutes.\n\n" \
|
|
195
|
+
" To continue, choose one:\n" \
|
|
196
|
+
" echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
|
|
197
|
+
" git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
|
|
198
|
+
" If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
|
|
155
199
|
end
|
|
156
200
|
|
|
157
201
|
def _random_delay(label)
|
data/lib/git_fit/auth/garmin.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative 'garmin/strategy_a'
|
|
4
4
|
require_relative 'garmin/strategy_b'
|
|
5
5
|
require_relative 'garmin/di_exchange'
|
|
6
|
+
require_relative 'garmin/login_session'
|
|
6
7
|
require_relative '../config'
|
|
7
8
|
require_relative '../cli/gh_cli'
|
|
8
9
|
require 'json'
|
|
@@ -48,10 +49,10 @@ module GitFit
|
|
|
48
49
|
if chosen.size > 1
|
|
49
50
|
raise GitFit::Sync::AuthError,
|
|
50
51
|
'git fit auth garmin: -A/-B/--auto/--sync are mutually exclusive — ' \
|
|
51
|
-
'use exactly one (default:
|
|
52
|
+
'use exactly one (default: --auto)'
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
chosen.first || :
|
|
55
|
+
chosen.first || :auto
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def flag_enabled?(flag)
|
|
@@ -88,7 +89,7 @@ module GitFit
|
|
|
88
89
|
|
|
89
90
|
def single_strategy(label, creds)
|
|
90
91
|
token, error = run_strategy(label, creds)
|
|
91
|
-
exit_with_error("Garmin auth failed: #{error}") unless token
|
|
92
|
+
exit_with_error("Garmin auth failed: #{error.message}") unless token
|
|
92
93
|
|
|
93
94
|
token
|
|
94
95
|
end
|
|
@@ -97,22 +98,35 @@ module GitFit
|
|
|
97
98
|
token, b_error = run_strategy(:B, creds)
|
|
98
99
|
return token if token
|
|
99
100
|
|
|
101
|
+
if b_error.is_a?(MfaRequired)
|
|
102
|
+
# B triggered MFA and saved the login session. Falling back to A
|
|
103
|
+
# would re-trigger a second MFA — stop and ask the user for the code.
|
|
104
|
+
exit_with_error(b_error.message)
|
|
105
|
+
end
|
|
106
|
+
|
|
100
107
|
warn '[auto] B failed, falling back to A'
|
|
101
108
|
token, a_error = run_strategy(:A, creds)
|
|
102
109
|
return token if token
|
|
103
110
|
|
|
104
|
-
exit_with_error(
|
|
111
|
+
exit_with_error(a_error.message) if a_error.is_a?(MfaRequired)
|
|
112
|
+
|
|
113
|
+
exit_with_error("Garmin auth failed: B: #{b_error.message}; A: #{a_error.message}")
|
|
105
114
|
end
|
|
106
115
|
|
|
107
116
|
def run_strategy(label, creds)
|
|
108
117
|
token = STRATEGIES.fetch(label).call(**creds)
|
|
109
|
-
return [token, nil] if json_round_trips?(token)
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
return [token, nil] if token && json_round_trips?(token)
|
|
119
|
+
|
|
120
|
+
message = if token.nil?
|
|
121
|
+
"Strategy #{label} failed (no token returned)"
|
|
122
|
+
else
|
|
123
|
+
"Strategy #{label} returned a token that fails JSON round-trip"
|
|
124
|
+
end
|
|
125
|
+
[nil, RuntimeError.new(message)]
|
|
112
126
|
rescue GitFit::Sync::AuthError => e
|
|
113
|
-
[nil, e
|
|
127
|
+
[nil, e]
|
|
114
128
|
rescue StandardError => e
|
|
115
|
-
[nil,
|
|
129
|
+
[nil, e]
|
|
116
130
|
end
|
|
117
131
|
|
|
118
132
|
def json_round_trips?(token)
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -74,8 +74,8 @@ module GitFit
|
|
|
74
74
|
|
|
75
75
|
desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
|
|
76
76
|
option :A, type: :boolean, desc: 'Garmin Strategy A: curl-impersonate shellout'
|
|
77
|
-
option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright
|
|
78
|
-
option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A'
|
|
77
|
+
option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright'
|
|
78
|
+
option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A (default)'
|
|
79
79
|
option :sync, type: :boolean, desc: 'Garmin: promote cached token to auth_seed'
|
|
80
80
|
option :mfa_code, type: :string, desc: 'Garmin MFA code (from email), re-run to continue'
|
|
81
81
|
def auth(source)
|
data/lib/git_fit/sync/xingzhe.rb
CHANGED
|
@@ -22,14 +22,17 @@ module GitFit
|
|
|
22
22
|
|
|
23
23
|
RAW_EXT = 'json'
|
|
24
24
|
|
|
25
|
+
SESSION_TTL = 30 * 24 * 60 * 60
|
|
26
|
+
|
|
25
27
|
register_adapter
|
|
26
28
|
config_key 'xingzhe'
|
|
27
|
-
register_config :email, :password
|
|
29
|
+
register_config :email, :password, :session_path
|
|
28
30
|
|
|
29
31
|
def initialize(...)
|
|
30
32
|
super
|
|
31
33
|
@email = @config['email']
|
|
32
34
|
@password = @config['password']
|
|
35
|
+
@session_path = @config['session_path'] || File.join('data', 'cache', 'xingzhe_session.json')
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def before_call
|
|
@@ -38,9 +41,15 @@ module GitFit
|
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
def authenticate
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
if load_session && session_valid?
|
|
45
|
+
@auth_method = 'cached'
|
|
46
|
+
return true
|
|
47
|
+
end
|
|
48
|
+
if login
|
|
49
|
+
@auth_method = 'login'
|
|
50
|
+
return true
|
|
51
|
+
end
|
|
52
|
+
false
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
def pending_ids
|
|
@@ -67,6 +76,14 @@ module GitFit
|
|
|
67
76
|
process_activity(pending[:summary], pending[:type])
|
|
68
77
|
end
|
|
69
78
|
|
|
79
|
+
def auth_error?
|
|
80
|
+
[302, 401, 403].include?(@last_http_code)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def last_auth_code
|
|
84
|
+
@last_http_code
|
|
85
|
+
end
|
|
86
|
+
|
|
70
87
|
private
|
|
71
88
|
|
|
72
89
|
def http_request(method, path, body: nil, params: nil, headers: {})
|
|
@@ -85,7 +102,26 @@ module GitFit
|
|
|
85
102
|
http.use_ssl = true
|
|
86
103
|
http.open_timeout = 30
|
|
87
104
|
http.read_timeout = 60
|
|
88
|
-
http.start { |h| h.request(req) }
|
|
105
|
+
resp = http.start { |h| h.request(req) }
|
|
106
|
+
@last_http_code = resp.code.to_i
|
|
107
|
+
resp
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def authed_get(path, params: nil)
|
|
111
|
+
resp = http_request(:get, path, params: params, headers: @headers || {})
|
|
112
|
+
return resp unless auth_failure?(resp)
|
|
113
|
+
|
|
114
|
+
remove_stale_session
|
|
115
|
+
return resp unless login
|
|
116
|
+
|
|
117
|
+
http_request(:get, path, params: params, headers: @headers)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def auth_failure?(resp)
|
|
121
|
+
return false unless resp
|
|
122
|
+
c = resp.code.to_i
|
|
123
|
+
(c == 400 && resp.body.to_s.include?('not allowed')) ||
|
|
124
|
+
[302, 401, 403].include?(c)
|
|
89
125
|
end
|
|
90
126
|
|
|
91
127
|
def login
|
|
@@ -102,10 +138,57 @@ module GitFit
|
|
|
102
138
|
cookie = extract_cookie(resp)
|
|
103
139
|
return false unless cookie
|
|
104
140
|
|
|
141
|
+
@session_expires_at = parse_max_age(resp)
|
|
142
|
+
@headers = { 'Cookie' => cookie, 'Accept' => 'application/json' }
|
|
143
|
+
save_session(cookie)
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def load_session
|
|
148
|
+
return false unless File.exist?(@session_path)
|
|
149
|
+
|
|
150
|
+
data = JSON.parse(File.read(@session_path))
|
|
151
|
+
cookie = data['cookie']
|
|
152
|
+
@session_expires_at = data['expires_at']
|
|
153
|
+
return false unless cookie
|
|
154
|
+
|
|
105
155
|
@headers = { 'Cookie' => cookie, 'Accept' => 'application/json' }
|
|
106
156
|
true
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
warn "XingZhe: failed to load cached session: #{e.message}"
|
|
159
|
+
remove_stale_session
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def session_valid?
|
|
164
|
+
return false unless @session_expires_at
|
|
165
|
+
Time.now.to_i < @session_expires_at - 60
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def save_session(cookie)
|
|
169
|
+
FileUtils.mkdir_p(File.dirname(@session_path))
|
|
170
|
+
File.write(@session_path, JSON.generate({
|
|
171
|
+
cookie: cookie,
|
|
172
|
+
expires_at: @session_expires_at,
|
|
173
|
+
}))
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
warn "XingZhe: failed to save session: #{e.message}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def remove_stale_session
|
|
179
|
+
File.delete(@session_path) if File.exist?(@session_path)
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
warn "XingZhe: failed to remove stale session: #{e.message}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_max_age(resp)
|
|
185
|
+
set_cookie = resp['set-cookie'].to_s
|
|
186
|
+
ma = set_cookie[/Max-Age=(\d+)/, 1]
|
|
187
|
+
ma ? Time.now.to_i + ma.to_i : Time.now.to_i + SESSION_TTL
|
|
107
188
|
end
|
|
108
189
|
|
|
190
|
+
attr_reader :session_path
|
|
191
|
+
|
|
109
192
|
def rsa_encrypt(password)
|
|
110
193
|
pem = "-----BEGIN PUBLIC KEY-----\n#{RSA_PUBKEY}\n-----END PUBLIC KEY-----"
|
|
111
194
|
key = OpenSSL::PKey::RSA.new(pem)
|
|
@@ -124,9 +207,8 @@ module GitFit
|
|
|
124
207
|
end
|
|
125
208
|
|
|
126
209
|
def fetch_page(offset, limit)
|
|
127
|
-
resp =
|
|
210
|
+
resp = authed_get(WORKOUTS_PATH,
|
|
128
211
|
params: { offset: offset, limit: limit },
|
|
129
|
-
headers: @headers
|
|
130
212
|
)
|
|
131
213
|
return nil unless resp.code.to_i == 200
|
|
132
214
|
|
|
@@ -202,7 +284,7 @@ module GitFit
|
|
|
202
284
|
end
|
|
203
285
|
|
|
204
286
|
def fetch_stream(id)
|
|
205
|
-
resp =
|
|
287
|
+
resp = authed_get("#{STREAM_PATH}#{id}/stream/")
|
|
206
288
|
return nil unless resp.code.to_i == 200
|
|
207
289
|
|
|
208
290
|
JSON.parse(resp.body).dig('data')
|
data/lib/git_fit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-fit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -248,6 +248,7 @@ files:
|
|
|
248
248
|
- lib/git-fit.rb
|
|
249
249
|
- lib/git_fit/auth/garmin.rb
|
|
250
250
|
- lib/git_fit/auth/garmin/di_exchange.rb
|
|
251
|
+
- lib/git_fit/auth/garmin/login_session.rb
|
|
251
252
|
- lib/git_fit/auth/garmin/strategy_a.rb
|
|
252
253
|
- lib/git_fit/auth/garmin/strategy_b.rb
|
|
253
254
|
- lib/git_fit/auth/garmin_token.rb
|