@gooin/garmin-connect 1.6.11 → 1.7.2
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.
- package/dist/common/HttpClient copy.d.ts +38 -0
- package/dist/common/HttpClient copy.js +361 -0
- package/dist/common/HttpClient copy.js.map +1 -0
- package/dist/common/HttpClient.d.ts +142 -7
- package/dist/common/HttpClient.js +393 -145
- package/dist/common/HttpClient.js.map +1 -1
- package/dist/common/HttpClientOLD.d.ts +38 -0
- package/dist/common/HttpClientOLD.js +361 -0
- package/dist/common/HttpClientOLD.js.map +1 -0
- package/dist/common/HttpClientOLDV1.d.ts +38 -0
- package/dist/common/HttpClientOLDV1.js +361 -0
- package/dist/common/HttpClientOLDV1.js.map +1 -0
- package/dist/common/HttpClientV1.d.ts +77 -0
- package/dist/common/HttpClientV1.js +577 -0
- package/dist/common/HttpClientV1.js.map +1 -0
- package/dist/common/MFAManager.d.ts +68 -0
- package/dist/common/MFAManager.js +269 -0
- package/dist/common/MFAManager.js.map +1 -0
- package/dist/garmin/GarminConnect.d.ts +1 -1
- package/dist/garmin/GarminConnect.js +4 -3
- package/dist/garmin/GarminConnect.js.map +1 -1
- package/dist/garmin/types/index.d.ts +1 -0
- package/dist/garmin/types/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
|
@@ -10,79 +10,60 @@ const lodash_1 = __importDefault(require("lodash"));
|
|
|
10
10
|
const luxon_1 = require("luxon");
|
|
11
11
|
const oauth_1_0a_1 = __importDefault(require("oauth-1.0a"));
|
|
12
12
|
const qs_1 = __importDefault(require("qs"));
|
|
13
|
+
const MFAManager_1 = require("./MFAManager");
|
|
13
14
|
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
15
|
+
const tough_cookie_1 = require("tough-cookie");
|
|
16
|
+
const axios_cookiejar_support_1 = require("axios-cookiejar-support");
|
|
17
|
+
// 正则表达式常量
|
|
14
18
|
const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"');
|
|
15
19
|
const TICKET_RE = new RegExp('ticket=([^"]+)"');
|
|
16
20
|
const ACCOUNT_LOCKED_RE = new RegExp('var statuss*=s*"([^"]*)"');
|
|
17
21
|
const PAGE_TITLE_RE = new RegExp('<title>([^<]*)</title>');
|
|
22
|
+
// 用户代理常量
|
|
18
23
|
const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile';
|
|
19
24
|
const USER_AGENT_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36';
|
|
20
25
|
const USER_AGENT_BROWSER_MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
26
|
+
// URL常量
|
|
21
27
|
const OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json';
|
|
28
|
+
// HTTP状态码常量
|
|
22
29
|
const HTTP_STATUS = {
|
|
23
30
|
UNAUTHORIZED: 401
|
|
24
31
|
};
|
|
32
|
+
// 全局变量
|
|
25
33
|
let tokenRefreshPromise = null;
|
|
26
34
|
let refreshSubscribers = [];
|
|
27
35
|
class HttpClient {
|
|
28
36
|
constructor(url, config) {
|
|
29
37
|
var _a, _b;
|
|
38
|
+
const jar = new tough_cookie_1.CookieJar();
|
|
30
39
|
this.url = url;
|
|
31
|
-
this.client = axios_1.default.create({
|
|
40
|
+
this.client = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
|
|
32
41
|
timeout: (_a = config === null || config === void 0 ? void 0 : config.timeout) !== null && _a !== void 0 ? _a : 5000,
|
|
33
|
-
timeoutErrorMessage: `Request Timeout: > ${(_b = config === null || config === void 0 ? void 0 : config.timeout) !== null && _b !== void 0 ? _b : 5000} ms
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// protocol: 'http'
|
|
42
|
-
// },
|
|
43
|
-
// httpsAgent: new (require('https').Agent)({
|
|
44
|
-
// rejectUnauthorized: false
|
|
45
|
-
// })
|
|
46
|
-
});
|
|
42
|
+
timeoutErrorMessage: `Request Timeout: > ${(_b = config === null || config === void 0 ? void 0 : config.timeout) !== null && _b !== void 0 ? _b : 5000} ms`,
|
|
43
|
+
maxRedirects: 10,
|
|
44
|
+
validateStatus: function (status) {
|
|
45
|
+
return status >= 200 && status < 400;
|
|
46
|
+
},
|
|
47
|
+
withCredentials: true,
|
|
48
|
+
jar: jar
|
|
49
|
+
}));
|
|
47
50
|
this.config = config;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!tokenRefreshPromise) {
|
|
63
|
-
tokenRefreshPromise =
|
|
64
|
-
this.refreshOauth2Token().finally(() => {
|
|
65
|
-
tokenRefreshPromise = null;
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
await tokenRefreshPromise;
|
|
69
|
-
originalRequest.headers.Authorization = `Bearer ${this.oauth2Token.access_token}`;
|
|
70
|
-
return this.client(originalRequest);
|
|
71
|
-
}
|
|
72
|
-
catch (err) {
|
|
73
|
-
console.error('Token refresh failed:', err);
|
|
74
|
-
throw err;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (axios_1.default.isAxiosError(error) && error.response) {
|
|
78
|
-
this.handleError(error.response);
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
// 处理没有response的情况
|
|
82
|
-
throw new Error('Network error or unknown error occurred');
|
|
83
|
-
}
|
|
84
|
-
throw error;
|
|
51
|
+
const mfaStorageDir = config.mfaStorageDir || './shared-mfa-sessions';
|
|
52
|
+
this.mfaManager = MFAManager_1.MFAManager.getInstance(mfaStorageDir);
|
|
53
|
+
this.setupInterceptors();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 设置请求和响应拦截器
|
|
57
|
+
*/
|
|
58
|
+
setupInterceptors() {
|
|
59
|
+
// 响应拦截器
|
|
60
|
+
this.client.interceptors.response.use((response) => {
|
|
61
|
+
// this.logResponseTracking(response);
|
|
62
|
+
return response;
|
|
63
|
+
}, async (error) => {
|
|
64
|
+
return this.handleResponseError(error);
|
|
85
65
|
});
|
|
66
|
+
// 请求拦截器
|
|
86
67
|
this.client.interceptors.request.use(async (config) => {
|
|
87
68
|
if (this.oauth2Token) {
|
|
88
69
|
config.headers.Authorization =
|
|
@@ -91,6 +72,66 @@ class HttpClient {
|
|
|
91
72
|
return config;
|
|
92
73
|
});
|
|
93
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* 记录响应跟踪信息
|
|
77
|
+
*/
|
|
78
|
+
logResponseTracking(response) {
|
|
79
|
+
var _a, _b, _c, _d;
|
|
80
|
+
if (((_a = response.config.url) === null || _a === void 0 ? void 0 : _a.includes('signin')) ||
|
|
81
|
+
((_b = response.config.url) === null || _b === void 0 ? void 0 : _b.includes('verifyMFA'))) {
|
|
82
|
+
console.log('> 响应跟踪 - URL:', response.config.url);
|
|
83
|
+
console.log('响应跟踪 - 状态码:', response.status);
|
|
84
|
+
console.log('响应跟踪 - 最终URL:', ((_c = response.request) === null || _c === void 0 ? void 0 : _c.responseURL) || response.config.url);
|
|
85
|
+
console.log('响应跟踪 - 重定向次数:', ((_d = response.request) === null || _d === void 0 ? void 0 : _d.redirectCount) || 0);
|
|
86
|
+
if (response.headers.location) {
|
|
87
|
+
console.log('响应跟踪 - Location头:', response.headers.location);
|
|
88
|
+
}
|
|
89
|
+
if (response.status >= 300 && response.status < 400) {
|
|
90
|
+
console.log('响应跟踪 - 检测到重定向状态码:', response.status);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 处理响应错误
|
|
96
|
+
*/
|
|
97
|
+
async handleResponseError(error) {
|
|
98
|
+
var _a;
|
|
99
|
+
if (axios_1.default.isAxiosError(error) && error.code === 'ECONNABORTED') {
|
|
100
|
+
throw new Error(error.message || 'Request Timeout');
|
|
101
|
+
}
|
|
102
|
+
const originalRequest = error.config;
|
|
103
|
+
if (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) === HTTP_STATUS.UNAUTHORIZED &&
|
|
104
|
+
!(originalRequest === null || originalRequest === void 0 ? void 0 : originalRequest._retry)) {
|
|
105
|
+
if (!this.oauth2Token) {
|
|
106
|
+
throw new Error('No OAuth2 token available');
|
|
107
|
+
}
|
|
108
|
+
originalRequest._retry = true;
|
|
109
|
+
try {
|
|
110
|
+
if (!tokenRefreshPromise) {
|
|
111
|
+
tokenRefreshPromise = this.refreshOauth2Token().finally(() => {
|
|
112
|
+
tokenRefreshPromise = null;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
await tokenRefreshPromise;
|
|
116
|
+
originalRequest.headers.Authorization = `Bearer ${this.oauth2Token.access_token}`;
|
|
117
|
+
return this.client(originalRequest);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.error('Token refresh failed:', err);
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (axios_1.default.isAxiosError(error) && error.response) {
|
|
125
|
+
this.handleError(error.response);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
throw new Error('Network error or unknown error occurred');
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 获取OAuth消费者信息
|
|
134
|
+
*/
|
|
94
135
|
async fetchOauthConsumer() {
|
|
95
136
|
const response = await axios_1.default.get(OAUTH_CONSUMER_URL);
|
|
96
137
|
this.OAUTH_CONSUMER = {
|
|
@@ -98,6 +139,9 @@ class HttpClient {
|
|
|
98
139
|
secret: response.data.consumer_secret
|
|
99
140
|
};
|
|
100
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* 检查令牌有效性
|
|
144
|
+
*/
|
|
101
145
|
async checkTokenVaild() {
|
|
102
146
|
if (this.oauth2Token) {
|
|
103
147
|
if (this.oauth2Token.expires_at < luxon_1.DateTime.now().toSeconds()) {
|
|
@@ -106,18 +150,30 @@ class HttpClient {
|
|
|
106
150
|
}
|
|
107
151
|
}
|
|
108
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* GET请求
|
|
155
|
+
*/
|
|
109
156
|
async get(url, config) {
|
|
110
157
|
const response = await this.client.get(url, config);
|
|
111
158
|
return response === null || response === void 0 ? void 0 : response.data;
|
|
112
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* POST请求
|
|
162
|
+
*/
|
|
113
163
|
async post(url, data, config) {
|
|
114
164
|
const response = await this.client.post(url, data, config);
|
|
115
165
|
return response === null || response === void 0 ? void 0 : response.data;
|
|
116
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* PUT请求
|
|
169
|
+
*/
|
|
117
170
|
async put(url, data, config) {
|
|
118
171
|
const response = await this.client.put(url, data, config);
|
|
119
172
|
return response === null || response === void 0 ? void 0 : response.data;
|
|
120
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* DELETE请求
|
|
176
|
+
*/
|
|
121
177
|
async delete(url, config) {
|
|
122
178
|
const response = await this.client.post(url, null, {
|
|
123
179
|
...config,
|
|
@@ -128,14 +184,23 @@ class HttpClient {
|
|
|
128
184
|
});
|
|
129
185
|
return response === null || response === void 0 ? void 0 : response.data;
|
|
130
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* 设置通用请求头
|
|
189
|
+
*/
|
|
131
190
|
setCommonHeader(headers) {
|
|
132
191
|
lodash_1.default.each(headers, (headerValue, key) => {
|
|
133
192
|
this.client.defaults.headers.common[key] = headerValue;
|
|
134
193
|
});
|
|
135
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* 处理错误
|
|
197
|
+
*/
|
|
136
198
|
handleError(response) {
|
|
137
199
|
this.handleHttpError(response);
|
|
138
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* 处理HTTP错误
|
|
203
|
+
*/
|
|
139
204
|
handleHttpError(response) {
|
|
140
205
|
const { status, statusText, data } = response;
|
|
141
206
|
const errorMessage = {
|
|
@@ -147,134 +212,313 @@ class HttpClient {
|
|
|
147
212
|
throw new Error(`HTTP Error (${status}): ${statusText}`);
|
|
148
213
|
}
|
|
149
214
|
/**
|
|
150
|
-
*
|
|
151
|
-
* @param username
|
|
152
|
-
* @param password
|
|
153
|
-
* @
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
215
|
+
* 登录到Garmin Connect
|
|
216
|
+
* @param username 用户名
|
|
217
|
+
* @param password 密码
|
|
218
|
+
* @param mfaCallback MFA验证回调函数
|
|
219
|
+
* @param sessionId 会话ID,用于分步登录
|
|
220
|
+
* @returns Promise<HttpClient>
|
|
221
|
+
*/
|
|
222
|
+
async login(username, password, sessionId) {
|
|
223
|
+
try {
|
|
224
|
+
// 准备登录
|
|
225
|
+
await this.fetchOauthConsumer();
|
|
226
|
+
// 获取登录票据
|
|
227
|
+
const ticket = await this.getLoginTicket(username, password, sessionId);
|
|
228
|
+
// 获取OAuth1令牌
|
|
229
|
+
const oauth1 = await this.getOauth1Token(ticket);
|
|
230
|
+
// 交换OAuth2令牌
|
|
231
|
+
await this.exchange(oauth1);
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
console.error('Login failed:', error);
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 获取登录票据
|
|
241
|
+
* @param username 用户名
|
|
242
|
+
* @param password 密码
|
|
243
|
+
* @param mfaCallback MFA验证回调函数
|
|
244
|
+
* @param sessionId 会话ID,用于分步登录
|
|
245
|
+
* @returns 登录票据
|
|
246
|
+
*/
|
|
247
|
+
async getLoginTicket(username, password, sessionId) {
|
|
248
|
+
// 准备登录参数
|
|
249
|
+
const loginParams = this.prepareLoginParams();
|
|
250
|
+
// 步骤1: 设置cookie
|
|
251
|
+
await this.performLoginStep1(loginParams.step1Params);
|
|
252
|
+
// 步骤2: 获取CSRF令牌
|
|
253
|
+
const csrfToken = await this.performLoginStep2(loginParams.step2Params);
|
|
254
|
+
// 步骤3: 提交凭据
|
|
255
|
+
let signinResult = await this.performLoginStep3(username, password, csrfToken, loginParams.step3Params);
|
|
256
|
+
// 检查账户锁定状态
|
|
257
|
+
this.handleAccountLocked(signinResult);
|
|
258
|
+
// 检查页面标题,判断是否需要MFA
|
|
259
|
+
const pageTitle = this.handlePageTitle(signinResult);
|
|
260
|
+
// 如果需要MFA,执行MFA验证
|
|
261
|
+
if (this.isMFARequired(pageTitle)) {
|
|
262
|
+
// 如果提供了sessionId,则使用分步登录模式
|
|
263
|
+
if (sessionId) {
|
|
264
|
+
// 等待外部提供验证码
|
|
265
|
+
const mfaCode = await MFAManager_1.MFAManager.getInstance().waitForMFACode(sessionId);
|
|
266
|
+
// 使用获取到的验证码完成MFA验证
|
|
267
|
+
signinResult = await this.handleMFAWithCode(signinResult, loginParams.step3Params, mfaCode);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
throw new Error('需要MFA验证,但未提供验证码获取方式');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// 提取票据
|
|
274
|
+
const ticket = this.extractTicket(signinResult);
|
|
275
|
+
if (!ticket) {
|
|
276
|
+
throw new Error('登录失败(未找到票据或MFA验证失败),请检查用户名和密码');
|
|
277
|
+
}
|
|
278
|
+
return ticket;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 准备登录参数
|
|
282
|
+
*/
|
|
283
|
+
prepareLoginParams() {
|
|
284
|
+
return {
|
|
285
|
+
step1Params: {
|
|
286
|
+
clientId: 'GarminConnect',
|
|
287
|
+
locale: 'en',
|
|
288
|
+
service: this.url.GC_MODERN
|
|
289
|
+
},
|
|
290
|
+
step2Params: {
|
|
291
|
+
id: 'gauth-widget',
|
|
292
|
+
embedWidget: true,
|
|
293
|
+
locale: 'en',
|
|
294
|
+
gauthHost: this.url.GARMIN_SSO_EMBED
|
|
295
|
+
},
|
|
296
|
+
step3Params: {
|
|
297
|
+
id: 'gauth-widget',
|
|
298
|
+
embedWidget: true,
|
|
299
|
+
clientId: 'GarminConnect',
|
|
300
|
+
locale: 'en',
|
|
301
|
+
gauthHost: this.url.GARMIN_SSO_EMBED,
|
|
302
|
+
service: this.url.GARMIN_SSO_EMBED,
|
|
303
|
+
source: this.url.GARMIN_SSO_EMBED,
|
|
304
|
+
redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED,
|
|
305
|
+
redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED
|
|
306
|
+
}
|
|
172
307
|
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* 执行登录步骤1:设置cookie
|
|
311
|
+
*/
|
|
312
|
+
async performLoginStep1(step1Params) {
|
|
173
313
|
const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs_1.default.stringify(step1Params)}`;
|
|
174
|
-
// console.log('login - step1Url:', step1Url);
|
|
175
314
|
await this.client.get(step1Url);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
gauthHost: this.url.GARMIN_SSO_EMBED
|
|
182
|
-
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* 执行登录步骤2:获取CSRF令牌
|
|
318
|
+
*/
|
|
319
|
+
async performLoginStep2(step2Params) {
|
|
183
320
|
const step2Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(step2Params)}`;
|
|
184
|
-
// console.log('login - step2Url:', step2Url);
|
|
185
321
|
const step2Result = await this.get(step2Url);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
throw new Error('login - csrf not found');
|
|
322
|
+
const csrfToken = this.extractCsrfToken(step2Result);
|
|
323
|
+
if (!csrfToken) {
|
|
324
|
+
throw new Error('登录 - 未找到CSRF令牌');
|
|
190
325
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
gauthHost: this.url.GARMIN_SSO_EMBED,
|
|
200
|
-
service: this.url.GARMIN_SSO_EMBED,
|
|
201
|
-
source: this.url.GARMIN_SSO_EMBED,
|
|
202
|
-
redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED,
|
|
203
|
-
redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED
|
|
204
|
-
};
|
|
205
|
-
const step3Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(signinParams)}`;
|
|
206
|
-
// console.log('login - step3Url:', step3Url);
|
|
326
|
+
return csrfToken;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* 执行登录步骤3:提交凭据
|
|
330
|
+
*/
|
|
331
|
+
async performLoginStep3(username, password, csrfToken, step3Params) {
|
|
332
|
+
const step3Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(step3Params)}`;
|
|
333
|
+
// console.log('🚀 - getLoginTicket - step3Url:', step3Url);
|
|
207
334
|
const step3Form = new form_data_1.default();
|
|
208
335
|
step3Form.append('username', username);
|
|
209
336
|
step3Form.append('password', password);
|
|
210
337
|
step3Form.append('embed', 'true');
|
|
211
|
-
step3Form.append('_csrf',
|
|
212
|
-
|
|
338
|
+
step3Form.append('_csrf', csrfToken);
|
|
339
|
+
return this.post(step3Url, step3Form, {
|
|
213
340
|
headers: {
|
|
214
341
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
215
342
|
Dnt: 1,
|
|
216
343
|
Origin: this.url.GARMIN_SSO_ORIGIN,
|
|
217
344
|
Referer: this.url.SIGNIN_URL,
|
|
218
|
-
'User-Agent':
|
|
345
|
+
'User-Agent': USER_AGENT_CONNECTMOBILE
|
|
219
346
|
}
|
|
220
347
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* 判断是否需要MFA验证
|
|
351
|
+
*/
|
|
352
|
+
isMFARequired(pageTitle) {
|
|
353
|
+
return pageTitle.toLowerCase().includes('mfa');
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 从响应中提取票据
|
|
357
|
+
*/
|
|
358
|
+
extractTicket(signinResult) {
|
|
359
|
+
const ticketRegResult = TICKET_RE.exec(signinResult);
|
|
360
|
+
return ticketRegResult ? ticketRegResult[1] : null;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 处理MFA验证(使用直接提供的验证码)
|
|
364
|
+
* @param htmlStr HTML响应字符串
|
|
365
|
+
* @param signinParams 登录参数
|
|
366
|
+
* @param mfaCode MFA验证码
|
|
367
|
+
* @returns MFA验证后的响应字符串
|
|
368
|
+
*/
|
|
369
|
+
async handleMFAWithCode(htmlStr, signinParams, mfaCode) {
|
|
370
|
+
try {
|
|
371
|
+
// 提取CSRF令牌
|
|
372
|
+
const csrfToken = this.extractCsrfToken(htmlStr);
|
|
373
|
+
if (!csrfToken) {
|
|
374
|
+
throw new Error('MFA验证 - 未找到CSRF令牌');
|
|
375
|
+
}
|
|
376
|
+
// 提交MFA验证码
|
|
377
|
+
const mfaResult = await this.submitMFACode(csrfToken, mfaCode, signinParams);
|
|
378
|
+
// 验证MFA结果
|
|
379
|
+
return this.validateMFAResult(mfaResult);
|
|
228
380
|
}
|
|
229
|
-
|
|
230
|
-
|
|
381
|
+
catch (error) {
|
|
382
|
+
console.error('MFA验证失败:', error);
|
|
383
|
+
throw new Error(`MFA验证失败: ${error}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* 处理MFA验证
|
|
388
|
+
* @param htmlStr HTML响应字符串
|
|
389
|
+
* @param signinParams 登录参数
|
|
390
|
+
* @param mfaCallback MFA验证回调函数
|
|
391
|
+
* @returns MFA验证后的响应字符串
|
|
392
|
+
*/
|
|
393
|
+
async handleMFA(htmlStr, signinParams, mfaCallback) {
|
|
394
|
+
// 验证MFA回调函数
|
|
395
|
+
if (!mfaCallback) {
|
|
396
|
+
throw new Error('登录失败(需要MFA验证),请提供MFA回调函数');
|
|
397
|
+
}
|
|
398
|
+
// 提取CSRF令牌
|
|
399
|
+
const csrfToken = this.extractCsrfToken(htmlStr);
|
|
400
|
+
// console.log('🚀 - handleMFA - csrfToken:', csrfToken);
|
|
401
|
+
if (!csrfToken) {
|
|
402
|
+
throw new Error('无法从MFA页面提取CSRF令牌');
|
|
403
|
+
}
|
|
404
|
+
// 获取MFA验证码
|
|
405
|
+
const mfaCode = await mfaCallback();
|
|
406
|
+
console.log('🚀 - handleMFA - mfaCode:', mfaCode);
|
|
407
|
+
// 提交MFA验证
|
|
408
|
+
const mfaResult = await this.submitMFACode(csrfToken, mfaCode, signinParams);
|
|
409
|
+
// 验证MFA结果
|
|
410
|
+
return this.validateMFAResult(mfaResult);
|
|
231
411
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
412
|
+
/**
|
|
413
|
+
* 提交MFA验证码
|
|
414
|
+
*/
|
|
415
|
+
async submitMFACode(csrfToken, mfaCode, signinParams) {
|
|
416
|
+
const SSO = this.url.GARMIN_SSO;
|
|
417
|
+
const mfaForm = new form_data_1.default();
|
|
418
|
+
mfaForm.append('mfa-code', mfaCode);
|
|
419
|
+
mfaForm.append('embed', 'true');
|
|
420
|
+
mfaForm.append('_csrf', csrfToken);
|
|
421
|
+
return this.post(`${SSO}/verifyMFA/loginEnterMfaCode`, mfaForm, {
|
|
422
|
+
params: signinParams,
|
|
423
|
+
headers: {
|
|
424
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
425
|
+
Dnt: 1,
|
|
426
|
+
Origin: this.url.GARMIN_SSO_ORIGIN,
|
|
427
|
+
Referer: `${SSO}/signin`,
|
|
428
|
+
'User-Agent': USER_AGENT_BROWSER
|
|
429
|
+
},
|
|
430
|
+
maxRedirects: 10,
|
|
431
|
+
transformResponse: [
|
|
432
|
+
function (data, headers) {
|
|
433
|
+
if (headers.location &&
|
|
434
|
+
headers.location.includes('logintoken')) {
|
|
435
|
+
console.log('检测到重定向到包含logintoken的URL:', headers.location);
|
|
436
|
+
}
|
|
437
|
+
return data;
|
|
438
|
+
}
|
|
439
|
+
]
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* 验证MFA结果
|
|
444
|
+
*/
|
|
445
|
+
validateMFAResult(mfaResult) {
|
|
446
|
+
// console.log('MFA验证完成:', mfaResult);
|
|
447
|
+
const pageTitle = this.handlePageTitle(mfaResult);
|
|
448
|
+
console.log('MFA验证后的页面标题:', pageTitle);
|
|
449
|
+
return mfaResult;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 从HTML中提取CSRF令牌
|
|
453
|
+
* @param html HTML字符串
|
|
454
|
+
* @returns CSRF令牌或null
|
|
455
|
+
*/
|
|
456
|
+
extractCsrfToken(html) {
|
|
457
|
+
const match = CSRF_RE.exec(html);
|
|
458
|
+
return match ? match[1] : null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* 处理页面标题
|
|
462
|
+
* @param htmlStr HTML字符串
|
|
463
|
+
* @returns 页面标题
|
|
464
|
+
*/
|
|
235
465
|
handlePageTitle(htmlStr) {
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
const title =
|
|
239
|
-
console.log('
|
|
466
|
+
const pageTitleRegResult = PAGE_TITLE_RE.exec(htmlStr);
|
|
467
|
+
if (pageTitleRegResult) {
|
|
468
|
+
const title = pageTitleRegResult[1];
|
|
469
|
+
console.log('登录页面标题:', title);
|
|
240
470
|
if (lodash_1.default.includes(title, 'Update Phone Number')) {
|
|
241
|
-
|
|
242
|
-
// See: https://github.com/matin/garth/issues/19
|
|
243
|
-
throw new Error('login failed (Update Phone number), please update your phone number, See: https://github.com/matin/garth/issues/19');
|
|
471
|
+
throw new Error('登录失败(需要更新电话号码),请更新您的电话号码,参考: https://github.com/matin/garth/issues/19');
|
|
244
472
|
}
|
|
473
|
+
return title;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
throw new Error('登录失败(未找到页面标题)');
|
|
245
477
|
}
|
|
246
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* 处理账户锁定状态
|
|
481
|
+
* @param htmlStr HTML字符串
|
|
482
|
+
*/
|
|
247
483
|
handleAccountLocked(htmlStr) {
|
|
248
484
|
const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr);
|
|
249
485
|
if (accountLockedRegResult) {
|
|
250
486
|
const msg = accountLockedRegResult[1];
|
|
251
487
|
console.error(msg);
|
|
252
|
-
throw new Error('
|
|
488
|
+
throw new Error('登录失败(账户已锁定),请打开Connect网页解锁您的账户');
|
|
253
489
|
}
|
|
254
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* 刷新OAuth2令牌
|
|
493
|
+
*/
|
|
255
494
|
async refreshOauth2Token() {
|
|
256
495
|
try {
|
|
257
496
|
if (!this.OAUTH_CONSUMER) {
|
|
258
497
|
await this.fetchOauthConsumer();
|
|
259
498
|
}
|
|
260
499
|
if (!this.oauth2Token || !this.oauth1Token) {
|
|
261
|
-
throw new Error('
|
|
500
|
+
throw new Error('缺少刷新令牌所需的必要令牌');
|
|
262
501
|
}
|
|
263
502
|
const oauth1 = {
|
|
264
503
|
oauth: this.getOauthClient(this.OAUTH_CONSUMER),
|
|
265
504
|
token: this.oauth1Token
|
|
266
505
|
};
|
|
267
506
|
await this.exchange(oauth1);
|
|
268
|
-
console.log(`「${this.config.username}
|
|
507
|
+
console.log(`「${this.config.username}」在「${this.url.domain}」的OAuth2令牌刷新成功`);
|
|
269
508
|
}
|
|
270
509
|
catch (error) {
|
|
271
|
-
console.error('
|
|
510
|
+
console.error('刷新OAuth2令牌失败:', error);
|
|
272
511
|
throw error;
|
|
273
512
|
}
|
|
274
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* 获取OAuth1令牌
|
|
516
|
+
* @param ticket 登录票据
|
|
517
|
+
* @returns OAuth1令牌和客户端
|
|
518
|
+
*/
|
|
275
519
|
async getOauth1Token(ticket) {
|
|
276
520
|
if (!this.OAUTH_CONSUMER) {
|
|
277
|
-
throw new Error('
|
|
521
|
+
throw new Error('未找到OAuth消费者信息');
|
|
278
522
|
}
|
|
279
523
|
const params = {
|
|
280
524
|
ticket,
|
|
@@ -283,26 +527,28 @@ class HttpClient {
|
|
|
283
527
|
};
|
|
284
528
|
const url = `${this.url.OAUTH_URL}/preauthorized?${qs_1.default.stringify(params)}`;
|
|
285
529
|
const oauth = this.getOauthClient(this.OAUTH_CONSUMER);
|
|
286
|
-
const
|
|
530
|
+
const requestData = {
|
|
287
531
|
url: url,
|
|
288
532
|
method: 'GET'
|
|
289
533
|
};
|
|
290
|
-
const headers = oauth.toHeader(oauth.authorize(
|
|
291
|
-
// console.log('getOauth1Token - headers:', headers);
|
|
534
|
+
const headers = oauth.toHeader(oauth.authorize(requestData));
|
|
292
535
|
const response = await this.get(url, {
|
|
293
536
|
headers: {
|
|
294
537
|
...headers,
|
|
295
538
|
'User-Agent': USER_AGENT_CONNECTMOBILE
|
|
296
539
|
}
|
|
297
540
|
});
|
|
298
|
-
// console.log('getOauth1Token - response:', response);
|
|
299
541
|
const token = qs_1.default.parse(response);
|
|
300
|
-
// console.log('getOauth1Token - token:', token);
|
|
301
542
|
this.oauth1Token = token;
|
|
302
543
|
return { token, oauth };
|
|
303
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* 获取OAuth客户端
|
|
547
|
+
* @param consumer OAuth消费者信息
|
|
548
|
+
* @returns OAuth客户端
|
|
549
|
+
*/
|
|
304
550
|
getOauthClient(consumer) {
|
|
305
|
-
|
|
551
|
+
return new oauth_1_0a_1.default({
|
|
306
552
|
consumer: consumer,
|
|
307
553
|
signature_method: 'HMAC-SHA1',
|
|
308
554
|
hash_function(base_string, key) {
|
|
@@ -312,25 +558,24 @@ class HttpClient {
|
|
|
312
558
|
.digest('base64');
|
|
313
559
|
}
|
|
314
560
|
});
|
|
315
|
-
return oauth;
|
|
316
561
|
}
|
|
317
|
-
|
|
562
|
+
/**
|
|
563
|
+
* 交换OAuth2令牌
|
|
564
|
+
* @param oauth1 OAuth1令牌和客户端
|
|
565
|
+
*/
|
|
318
566
|
async exchange(oauth1) {
|
|
319
567
|
const token = {
|
|
320
568
|
key: oauth1.token.oauth_token,
|
|
321
569
|
secret: oauth1.token.oauth_token_secret
|
|
322
570
|
};
|
|
323
|
-
// console.log('exchange - token:', token);
|
|
324
571
|
const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`;
|
|
325
572
|
const requestData = {
|
|
326
573
|
url: baseUrl,
|
|
327
574
|
method: 'POST',
|
|
328
575
|
data: null
|
|
329
576
|
};
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const url = `${baseUrl}?${qs_1.default.stringify(step5AuthData)}`;
|
|
333
|
-
// console.log('exchange - url:', url);
|
|
577
|
+
const authData = oauth1.oauth.authorize(requestData, token);
|
|
578
|
+
const url = `${baseUrl}?${qs_1.default.stringify(authData)}`;
|
|
334
579
|
this.oauth2Token = undefined;
|
|
335
580
|
const response = await this.post(url, null, {
|
|
336
581
|
headers: {
|
|
@@ -338,10 +583,13 @@ class HttpClient {
|
|
|
338
583
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
339
584
|
}
|
|
340
585
|
});
|
|
341
|
-
// console.log('exchange - response:', response);
|
|
342
586
|
this.oauth2Token = this.setOauth2TokenExpiresAt(response);
|
|
343
|
-
// console.log('exchange - oauth2Token:', this.oauth2Token);
|
|
344
587
|
}
|
|
588
|
+
/**
|
|
589
|
+
* 设置OAuth2令牌过期时间
|
|
590
|
+
* @param token OAuth2令牌
|
|
591
|
+
* @returns 设置了过期时间的OAuth2令牌
|
|
592
|
+
*/
|
|
345
593
|
setOauth2TokenExpiresAt(token) {
|
|
346
594
|
const now = luxon_1.DateTime.now();
|
|
347
595
|
const expiresAt = now.plus({ seconds: token.expires_in });
|