@gooin/garmin-connect
Version:
Makes it simple to interface with Garmin Connect to get or set any data point
612 lines • 22.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HttpClient = void 0;
const axios_1 = __importDefault(require("axios"));
const lodash_1 = __importDefault(require("lodash"));
const luxon_1 = require("luxon");
const oauth_1_0a_1 = __importDefault(require("oauth-1.0a"));
const qs_1 = __importDefault(require("qs"));
const MFAManager_1 = require("./MFAManager");
const node_crypto_1 = __importDefault(require("node:crypto"));
const tough_cookie_1 = require("tough-cookie");
const axios_cookiejar_support_1 = require("axios-cookiejar-support");
// 正则表达式常量
const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"');
const TICKET_RE = new RegExp('ticket=([^"]+)"');
const ACCOUNT_LOCKED_RE = new RegExp('var statuss*=s*"([^"]*)"');
const PAGE_TITLE_RE = new RegExp('<title>([^<]*)</title>');
// 用户代理常量
const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile';
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';
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';
// URL常量
const OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json';
// HTTP状态码常量
const HTTP_STATUS = {
UNAUTHORIZED: 401
};
// 全局变量
let tokenRefreshPromise = null;
let refreshSubscribers = [];
class HttpClient {
constructor(url, config) {
var _a, _b;
const jar = new tough_cookie_1.CookieJar();
this.url = url;
this.client = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
timeout: (_a = config === null || config === void 0 ? void 0 : config.timeout) !== null && _a !== void 0 ? _a : 5000,
timeoutErrorMessage: `Request Timeout: > ${(_b = config === null || config === void 0 ? void 0 : config.timeout) !== null && _b !== void 0 ? _b : 5000} ms`,
maxRedirects: 10,
validateStatus: function (status) {
return status >= 200 && status < 400;
},
withCredentials: true,
jar: jar
}));
this.config = config;
// 使用新的MFA配置初始化MFAManager
const mfaConfig = config.mfa || {
type: 'file',
dir: config.mfaStorageDir || '/tmp'
};
this.mfaManager = MFAManager_1.MFAManager.getInstance(mfaConfig);
this.setupInterceptors();
}
/**
* 设置请求和响应拦截器
*/
setupInterceptors() {
// 响应拦截器
this.client.interceptors.response.use((response) => {
// this.logResponseTracking(response);
return response;
}, async (error) => {
return this.handleResponseError(error);
});
// 请求拦截器
this.client.interceptors.request.use(async (config) => {
if (this.oauth2Token) {
config.headers.Authorization =
'Bearer ' + this.oauth2Token.access_token;
}
return config;
});
}
/**
* 记录响应跟踪信息
*/
logResponseTracking(response) {
var _a, _b, _c, _d;
if (((_a = response.config.url) === null || _a === void 0 ? void 0 : _a.includes('signin')) ||
((_b = response.config.url) === null || _b === void 0 ? void 0 : _b.includes('verifyMFA'))) {
console.log('> 响应跟踪 - URL:', response.config.url);
console.log('响应跟踪 - 状态码:', response.status);
console.log('响应跟踪 - 最终URL:', ((_c = response.request) === null || _c === void 0 ? void 0 : _c.responseURL) || response.config.url);
console.log('响应跟踪 - 重定向次数:', ((_d = response.request) === null || _d === void 0 ? void 0 : _d.redirectCount) || 0);
if (response.headers.location) {
console.log('响应跟踪 - Location头:', response.headers.location);
}
if (response.status >= 300 && response.status < 400) {
console.log('响应跟踪 - 检测到重定向状态码:', response.status);
}
}
}
/**
* 处理响应错误
*/
async handleResponseError(error) {
var _a;
if (axios_1.default.isAxiosError(error) && error.code === 'ECONNABORTED') {
throw new Error(error.message || 'Request Timeout');
}
const originalRequest = error.config;
if (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) === HTTP_STATUS.UNAUTHORIZED &&
!(originalRequest === null || originalRequest === void 0 ? void 0 : originalRequest._retry)) {
if (!this.oauth2Token) {
throw new Error('No OAuth2 token available');
}
originalRequest._retry = true;
try {
if (!tokenRefreshPromise) {
tokenRefreshPromise = this.refreshOauth2Token().finally(() => {
tokenRefreshPromise = null;
});
}
await tokenRefreshPromise;
originalRequest.headers.Authorization = `Bearer ${this.oauth2Token.access_token}`;
return this.client(originalRequest);
}
catch (err) {
console.error('Token refresh failed:', err);
throw err;
}
}
if (axios_1.default.isAxiosError(error) && error.response) {
this.handleError(error.response);
}
else {
throw new Error('Network error or unknown error occurred');
}
throw error;
}
/**
* 获取OAuth消费者信息
*/
async fetchOauthConsumer() {
const response = await axios_1.default.get(OAUTH_CONSUMER_URL);
this.OAUTH_CONSUMER = {
key: response.data.consumer_key,
secret: response.data.consumer_secret
};
}
/**
* 检查令牌有效性
*/
async checkTokenVaild() {
if (this.oauth2Token) {
if (this.oauth2Token.expires_at < luxon_1.DateTime.now().toSeconds()) {
console.error('Token expired!');
await this.refreshOauth2Token();
}
}
}
/**
* GET请求
*/
async get(url, config) {
const response = await this.client.get(url, config);
return response === null || response === void 0 ? void 0 : response.data;
}
/**
* POST请求
*/
async post(url, data, config) {
const response = await this.client.post(url, data, config);
return response === null || response === void 0 ? void 0 : response.data;
}
/**
* PUT请求
*/
async put(url, data, config) {
const response = await this.client.put(url, data, config);
return response === null || response === void 0 ? void 0 : response.data;
}
/**
* DELETE请求
*/
async delete(url, config) {
const response = await this.client.post(url, null, {
...config,
headers: {
...config === null || config === void 0 ? void 0 : config.headers,
'X-Http-Method-Override': 'DELETE'
}
});
return response === null || response === void 0 ? void 0 : response.data;
}
/**
* 设置通用请求头
*/
setCommonHeader(headers) {
lodash_1.default.each(headers, (headerValue, key) => {
this.client.defaults.headers.common[key] = headerValue;
});
}
/**
* 处理错误
*/
handleError(response) {
this.handleHttpError(response);
}
/**
* 处理HTTP错误
*/
handleHttpError(response) {
const { status, statusText, data } = response;
const errorMessage = {
status,
statusText,
data: typeof data === 'object' ? JSON.stringify(data) : data
};
console.error('HTTP Error:', errorMessage);
throw new Error(`HTTP Error (${status}): ${statusText}`);
}
/**
* 登录到Garmin Connect
* @param username 用户名
* @param password 密码
* @param mfaCallback MFA验证回调函数
* @param sessionId 会话ID,用于分步登录
* @returns Promise<HttpClient>
*/
async login(username, password, sessionId) {
try {
// 准备登录
await this.fetchOauthConsumer();
// 获取登录票据
const ticket = await this.getLoginTicket(username, password, sessionId);
// 获取OAuth1令牌
const oauth1 = await this.getOauth1Token(ticket);
// 交换OAuth2令牌
await this.exchange(oauth1);
return this;
}
catch (error) {
console.error('Login failed:', error);
throw error;
}
}
/**
* 获取登录票据
* @param username 用户名
* @param password 密码
* @param mfaCallback MFA验证回调函数
* @param sessionId 会话ID,用于分步登录
* @returns 登录票据
*/
async getLoginTicket(username, password, sessionId) {
// 准备登录参数
const loginParams = this.prepareLoginParams();
// 步骤1: 设置cookie
await this.performLoginStep1(loginParams.step1Params);
// 步骤2: 获取CSRF令牌
const csrfToken = await this.performLoginStep2(loginParams.step2Params);
// 步骤3: 提交凭据
let signinResult = await this.performLoginStep3(username, password, csrfToken, loginParams.step3Params);
// 检查账户锁定状态
this.handleAccountLocked(signinResult);
// 检查页面标题,判断是否需要MFA
const pageTitle = this.handlePageTitle(signinResult);
// 如果需要MFA,执行MFA验证
if (this.isMFARequired(pageTitle)) {
// 如果提供了sessionId,则使用分步登录模式
if (sessionId) {
// 等待外部提供验证码
const mfaCode = await this.mfaManager.waitForMFACode(sessionId);
// 使用获取到的验证码完成MFA验证
signinResult = await this.handleMFAWithCode(signinResult, loginParams.step3Params, mfaCode);
}
else {
throw new Error('需要MFA验证,但未提供验证码获取方式');
}
}
// 提取票据
const ticket = this.extractTicket(signinResult);
if (!ticket) {
throw new Error('登录失败(未找到票据或MFA验证失败),请检查用户名和密码');
}
return ticket;
}
/**
* 准备登录参数
*/
prepareLoginParams() {
return {
step1Params: {
clientId: 'GarminConnect',
locale: 'en',
service: this.url.GC_MODERN
},
step2Params: {
id: 'gauth-widget',
embedWidget: true,
locale: 'en',
gauthHost: this.url.GARMIN_SSO_EMBED
},
step3Params: {
id: 'gauth-widget',
embedWidget: true,
clientId: 'GarminConnect',
locale: 'en',
gauthHost: this.url.GARMIN_SSO_EMBED,
service: this.url.GARMIN_SSO_EMBED,
source: this.url.GARMIN_SSO_EMBED,
redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED,
redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED
}
};
}
/**
* 执行登录步骤1:设置cookie
*/
async performLoginStep1(step1Params) {
const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs_1.default.stringify(step1Params)}`;
await this.client.get(step1Url);
}
/**
* 执行登录步骤2:获取CSRF令牌
*/
async performLoginStep2(step2Params) {
const step2Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(step2Params)}`;
const step2Result = await this.get(step2Url);
const csrfToken = this.extractCsrfToken(step2Result);
if (!csrfToken) {
throw new Error('登录 - 未找到CSRF令牌');
}
return csrfToken;
}
/**
* 执行登录步骤3:提交凭据
*/
async performLoginStep3(username, password, csrfToken, step3Params) {
const step3Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(step3Params)}`;
// console.log('🚀 - getLoginTicket - step3Url:', step3Url);
const step3Form = new URLSearchParams();
step3Form.append('username', username);
step3Form.append('password', password);
step3Form.append('embed', 'true');
step3Form.append('_csrf', csrfToken);
return this.post(step3Url, step3Form, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Dnt: 1,
Origin: this.url.GARMIN_SSO_ORIGIN,
Referer: this.url.SIGNIN_URL,
'User-Agent': USER_AGENT_CONNECTMOBILE
}
});
}
/**
* 判断是否需要MFA验证
*/
isMFARequired(pageTitle) {
return pageTitle.toLowerCase().includes('mfa');
}
/**
* 从响应中提取票据
*/
extractTicket(signinResult) {
const ticketRegResult = TICKET_RE.exec(signinResult);
return ticketRegResult ? ticketRegResult[1] : null;
}
/**
* 处理MFA验证(使用直接提供的验证码)
* @param htmlStr HTML响应字符串
* @param signinParams 登录参数
* @param mfaCode MFA验证码
* @returns MFA验证后的响应字符串
*/
async handleMFAWithCode(htmlStr, signinParams, mfaCode) {
try {
// 提取CSRF令牌
const csrfToken = this.extractCsrfToken(htmlStr);
if (!csrfToken) {
throw new Error('MFA验证 - 未找到CSRF令牌');
}
// 提交MFA验证码
const mfaResult = await this.submitMFACode(csrfToken, mfaCode, signinParams);
// 验证MFA结果
return this.validateMFAResult(mfaResult);
}
catch (error) {
console.error('MFA验证失败:', error);
throw new Error(`MFA验证失败: ${error}`);
}
}
/**
* 处理MFA验证
* @param htmlStr HTML响应字符串
* @param signinParams 登录参数
* @param mfaCallback MFA验证回调函数
* @returns MFA验证后的响应字符串
*/
async handleMFA(htmlStr, signinParams, mfaCallback) {
// 验证MFA回调函数
if (!mfaCallback) {
throw new Error('登录失败(需要MFA验证),请提供MFA回调函数');
}
// 提取CSRF令牌
const csrfToken = this.extractCsrfToken(htmlStr);
// console.log('🚀 - handleMFA - csrfToken:', csrfToken);
if (!csrfToken) {
throw new Error('无法从MFA页面提取CSRF令牌');
}
// 获取MFA验证码
const mfaCode = await mfaCallback();
console.log('🚀 - handleMFA - mfaCode:', mfaCode);
// 提交MFA验证
const mfaResult = await this.submitMFACode(csrfToken, mfaCode, signinParams);
// 验证MFA结果
return this.validateMFAResult(mfaResult);
}
/**
* 提交MFA验证码
*/
async submitMFACode(csrfToken, mfaCode, signinParams) {
const SSO = this.url.GARMIN_SSO;
const mfaForm = new URLSearchParams();
mfaForm.append('mfa-code', mfaCode);
mfaForm.append('embed', 'true');
mfaForm.append('_csrf', csrfToken);
return this.post(`${SSO}/verifyMFA/loginEnterMfaCode`, mfaForm, {
params: signinParams,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Dnt: 1,
Origin: this.url.GARMIN_SSO_ORIGIN,
Referer: `${SSO}/signin`,
'User-Agent': USER_AGENT_BROWSER
},
maxRedirects: 10,
transformResponse: [
function (data, headers) {
if (headers.location &&
headers.location.includes('logintoken')) {
console.log('检测到重定向到包含logintoken的URL:', headers.location);
}
return data;
}
]
});
}
/**
* 验证MFA结果
*/
validateMFAResult(mfaResult) {
// console.log('MFA验证完成:', mfaResult);
const pageTitle = this.handlePageTitle(mfaResult);
console.log('MFA验证后的页面标题:', pageTitle);
return mfaResult;
}
/**
* 从HTML中提取CSRF令牌
* @param html HTML字符串
* @returns CSRF令牌或null
*/
extractCsrfToken(html) {
const match = CSRF_RE.exec(html);
return match ? match[1] : null;
}
/**
* 处理页面标题
* @param htmlStr HTML字符串
* @returns 页面标题
*/
handlePageTitle(htmlStr) {
const pageTitleRegResult = PAGE_TITLE_RE.exec(htmlStr);
if (pageTitleRegResult) {
const title = pageTitleRegResult[1];
console.log('登录页面标题:', title);
if (lodash_1.default.includes(title, 'Update Phone Number')) {
throw new Error('登录失败(需要更新电话号码),请更新您的电话号码,参考: https://github.com/matin/garth/issues/19');
}
return title;
}
else {
throw new Error('登录失败(未找到页面标题)');
}
}
/**
* 处理账户锁定状态
* @param htmlStr HTML字符串
*/
handleAccountLocked(htmlStr) {
const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr);
if (accountLockedRegResult) {
const msg = accountLockedRegResult[1];
console.error(msg);
throw new Error('登录失败(账户已锁定),请打开Connect网页解锁您的账户');
}
}
/**
* 刷新OAuth2令牌
*/
async refreshOauth2Token() {
try {
if (!this.OAUTH_CONSUMER) {
await this.fetchOauthConsumer();
}
if (!this.oauth2Token || !this.oauth1Token) {
throw new Error('缺少刷新令牌所需的必要令牌');
}
const oauth1 = {
oauth: this.getOauthClient(this.OAUTH_CONSUMER),
token: this.oauth1Token
};
await this.exchange(oauth1);
console.log(`「${this.config.username}」在「${this.url.domain}」的OAuth2令牌刷新成功`);
}
catch (error) {
console.error('刷新OAuth2令牌失败:', error);
throw error;
}
}
/**
* 获取OAuth1令牌
* @param ticket 登录票据
* @returns OAuth1令牌和客户端
*/
async getOauth1Token(ticket) {
if (!this.OAUTH_CONSUMER) {
throw new Error('未找到OAuth消费者信息');
}
const params = {
ticket,
'login-url': this.url.GARMIN_SSO_EMBED,
'accepts-mfa-tokens': true
};
const url = `${this.url.OAUTH_URL}/preauthorized?${qs_1.default.stringify(params)}`;
const oauth = this.getOauthClient(this.OAUTH_CONSUMER);
const requestData = {
url: url,
method: 'GET'
};
const headers = oauth.toHeader(oauth.authorize(requestData));
const response = await this.get(url, {
headers: {
...headers,
'User-Agent': USER_AGENT_CONNECTMOBILE
}
});
const token = qs_1.default.parse(response);
this.oauth1Token = token;
return { token, oauth };
}
/**
* 获取OAuth客户端
* @param consumer OAuth消费者信息
* @returns OAuth客户端
*/
getOauthClient(consumer) {
return new oauth_1_0a_1.default({
consumer: consumer,
signature_method: 'HMAC-SHA1',
hash_function(base_string, key) {
return node_crypto_1.default
.createHmac('sha1', key)
.update(base_string)
.digest('base64');
}
});
}
/**
* 交换OAuth2令牌
* @param oauth1 OAuth1令牌和客户端
*/
async exchange(oauth1) {
const token = {
key: oauth1.token.oauth_token,
secret: oauth1.token.oauth_token_secret
};
const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`;
const requestData = {
url: baseUrl,
method: 'POST',
data: null
};
const authData = oauth1.oauth.authorize(requestData, token);
const url = `${baseUrl}?${qs_1.default.stringify(authData)}`;
this.oauth2Token = undefined;
const response = await this.post(url, null, {
headers: {
'User-Agent': USER_AGENT_CONNECTMOBILE,
'Content-Type': 'application/x-www-form-urlencoded'
}
});
this.oauth2Token = this.setOauth2TokenExpiresAt(response);
}
/**
* 设置OAuth2令牌过期时间
* @param token OAuth2令牌
* @returns 设置了过期时间的OAuth2令牌
*/
setOauth2TokenExpiresAt(token) {
const now = luxon_1.DateTime.now();
const expiresAt = now.plus({ seconds: token.expires_in });
const refreshTokenExpiresAt = now.plus({
seconds: token.refresh_token_expires_in
});
return {
...token,
last_update_date: now.toLocal().toString(),
expires_date: expiresAt.toLocal().toString(),
expires_at: expiresAt.toSeconds(),
refresh_token_expires_at: refreshTokenExpiresAt.toSeconds()
};
}
}
exports.HttpClient = HttpClient;
//# sourceMappingURL=HttpClient.js.map