Compare commits

...

6 Commits

Author SHA1 Message Date
Roberto Viola
9b8c45b337 popup not needed 2026-01-31 20:18:35 +01:00
Roberto Viola
5ab7cd80f4 Merge branch 'master' into Garmin-Chinese-Server 2026-01-31 19:11:15 +00:00
Roberto Viola
d58f2536f2 Update garminconnect.h 2026-01-31 20:07:37 +01:00
Roberto Viola
c4852f3393 Detect MFA via page title and handle CSRF
Instead of scanning the entire response body for "MFA", detect MFA by parsing the HTML <title> (matching the Python garth approach) to avoid false positives from bodies that contain "MFA" text. Extract the page title early, check for "MFA" case-insensitively, and if detected update m_lastError, refresh cookies, extract a new CSRF token using two regex patterns, emit mfaRequired (unless suppressed), and abort the login flow. Also adjust the success check to rely on the title == "Success" and remove the legacy body-based MFA detection block. Added debugging logs for the title, CSRF token, and MFA signal paths.
2026-01-31 08:16:12 +01:00
Roberto Viola
28ae8d8a57 Add verbose debug logging for GarminConnect responses
Introduces a DEBUG_GARMIN_VERBOSE flag to enable detailed logging of HTTP responses and ticket extraction attempts in the GarminConnect authentication flow. This aids in troubleshooting login and MFA issues by providing more insight into response contents and extraction logic.
2026-01-30 08:44:32 +01:00
Roberto Viola
753548c97f Add Garmin server selection and debug logging
Introduces a ComboBox in settings to select between global and China Garmin servers, prompting for app restart when changed. Adds debug logging in garminconnect.cpp to trace domain and API URLs, and logs the loaded domain from settings.
2026-01-29 10:09:54 +01:00
4 changed files with 112 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git log:*)"
]
}
}

View File

@@ -391,6 +391,9 @@ bool GarminConnect::fetchCsrfToken()
bool GarminConnect::performLogin(const QString &email, const QString &password, bool suppressMfaSignal)
{
qDebug() << "GarminConnect: Performing login...";
qDebug() << "GarminConnect: Using domain:" << m_domain;
qDebug() << "GarminConnect: SSO URL:" << ssoUrl();
qDebug() << "GarminConnect: Connect API URL:" << connectApiUrl();
QString ssoEmbedUrl = ssoUrl() + SSO_EMBED_PATH;
@@ -452,15 +455,54 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
qDebug() << "GarminConnect: Login response length:" << response.length();
qDebug() << "GarminConnect: Response snippet:" << response.left(300);
// Check for success title (like Python garth library)
// Check page title (like Python garth library)
// garth checks ONLY the title for MFA detection, not the body
// This is important because some servers (like garmin.cn) may have "MFA" text
// in their Success page HTML body, which would cause false positives
QString pageTitle;
QRegularExpression titleRegex("<title>(.+?)</title>");
QRegularExpressionMatch titleMatch = titleRegex.match(response);
if (titleMatch.hasMatch()) {
QString title = titleMatch.captured(1);
qDebug() << "GarminConnect: Page title:" << title;
if (title == "Success") {
qDebug() << "GarminConnect: Login successful (Success page detected)";
pageTitle = titleMatch.captured(1);
qDebug() << "GarminConnect: Page title:" << pageTitle;
}
// Check if MFA is required by looking at the TITLE (garth approach)
// This is more reliable than checking the body which may contain "MFA" in scripts/URLs
if (pageTitle.contains("MFA", Qt::CaseInsensitive)) {
m_lastError = "MFA Required";
qDebug() << "GarminConnect: MFA detected in page title";
// Extract new CSRF token from MFA page - try multiple patterns
QRegularExpression csrfRegex1("name=\"_csrf\"[^>]*value=\"([^\"]+)\"");
QRegularExpression csrfRegex2("value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
QRegularExpressionMatch match = csrfRegex1.match(response);
if (!match.hasMatch()) {
match = csrfRegex2.match(response);
}
if (match.hasMatch()) {
m_csrfToken = match.captured(1);
qDebug() << "GarminConnect: CSRF token from MFA page:" << m_csrfToken.left(20) << "...";
}
// Update cookies
m_cookies = m_manager->cookieJar()->cookiesForUrl(url);
if (!suppressMfaSignal) {
qDebug() << "GarminConnect: Emitting mfaRequired signal";
emit mfaRequired();
} else {
qDebug() << "GarminConnect: MFA required but signal suppressed (retrying with MFA code)";
}
reply->deleteLater();
return false;
}
// Check if login was successful (title is "Success")
if (pageTitle == "Success") {
qDebug() << "GarminConnect: Login successful (Success page detected)";
// Continue to extract ticket below
}
// Check for error messages in response
@@ -549,39 +591,17 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
return false;
}
// Check if MFA is required (legacy check for non-redirect MFA)
if (response.contains("MFA", Qt::CaseInsensitive) ||
response.contains("Enter MFA Code", Qt::CaseInsensitive)) {
m_lastError = "MFA Required";
qDebug() << "GarminConnect: MFA content detected in response";
// Extract new CSRF token from MFA page - try multiple patterns
QRegularExpression csrfRegex1("name=\"_csrf\"[^>]*value=\"([^\"]+)\"");
QRegularExpression csrfRegex2("value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
QRegularExpressionMatch match = csrfRegex1.match(response);
if (!match.hasMatch()) {
match = csrfRegex2.match(response);
}
if (match.hasMatch()) {
m_csrfToken = match.captured(1);
}
// Update cookies
m_cookies = m_manager->cookieJar()->cookiesForUrl(url);
if (!suppressMfaSignal) {
emit mfaRequired();
}
reply->deleteLater();
return false;
}
// Extract ticket from response URL (already declared above)
if (responseUrl.isEmpty()) {
responseUrl = reply->url();
}
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Response URL:" << responseUrl.toString();
qDebug() << "GarminConnect: Response length:" << response.length();
qDebug() << "GarminConnect: Full response body:" << response;
}
QUrlQuery responseQuery(responseUrl);
QString ticket = responseQuery.queryItemValue("ticket");
@@ -599,6 +619,8 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
if (match.hasMatch()) {
ticket = match.captured(1);
qDebug() << "GarminConnect: Found ticket with fallback pattern:" << ticket.left(20) << "...";
} else if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: No ticket patterns matched in response body";
}
}
}
@@ -608,6 +630,9 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
if (ticket.isEmpty()) {
m_lastError = "Failed to extract ticket from login response";
qDebug() << "GarminConnect:" << m_lastError;
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Response snippet:" << response.left(1000);
}
return false;
}
@@ -708,8 +733,12 @@ void GarminConnect::handleMfaReplyFinished()
qDebug() << "GarminConnect: MFA response status code:" << statusCode;
qDebug() << "GarminConnect: MFA response redirect URL:" << responseUrl.toString();
// If no redirect, log response body to understand what happened
if (responseUrl.isEmpty()) {
// Log detailed response information
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: MFA response length:" << response.length();
qDebug() << "GarminConnect: Full MFA response body:" << response;
} else if (responseUrl.isEmpty()) {
// If no redirect, log response body to understand what happened (non-verbose)
qDebug() << "GarminConnect: MFA response body (first 500 chars):" << response.left(500);
}
@@ -748,6 +777,9 @@ void GarminConnect::handleMfaReplyFinished()
// If not found in redirect URL, try response body
if (ticket.isEmpty() && !response.isEmpty()) {
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Attempting to extract ticket from MFA response body";
}
// Try multiple patterns for ticket extraction
QRegularExpression ticketRegex1("embed\\?ticket=([^\"]+)\"");
QRegularExpression ticketRegex2("ticket=([^&\"']+)");
@@ -761,6 +793,16 @@ void GarminConnect::handleMfaReplyFinished()
if (match.hasMatch()) {
ticket = match.captured(1);
qDebug() << "GarminConnect: Found ticket in response body (pattern 2):" << ticket.left(20) << "...";
} else if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: No MFA ticket patterns matched. Checking for other patterns...";
// Check for JSON format
if (response.contains("ticket")) {
qDebug() << "GarminConnect: Response contains 'ticket' keyword, may be JSON or different format";
}
// Check for common response patterns
if (response.contains("\"")) {
qDebug() << "GarminConnect: Response contains quoted strings (may be JSON)";
}
}
}
}
@@ -770,6 +812,9 @@ void GarminConnect::handleMfaReplyFinished()
if (ticket.isEmpty()) {
m_lastError = "Failed to extract ticket after MFA";
qDebug() << "GarminConnect:" << m_lastError;
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Response snippet:" << response.left(1000);
}
emit authenticationFailed(m_lastError);
return;
}
@@ -1401,6 +1446,7 @@ void GarminConnect::loadTokensFromSettings()
m_oauth1Token.oauth_token = settings.value(QZSettings::garmin_oauth1_token, QZSettings::default_garmin_oauth1_token).toString();
m_oauth1Token.oauth_token_secret = settings.value(QZSettings::garmin_oauth1_token_secret, QZSettings::default_garmin_oauth1_token_secret).toString();
m_domain = settings.value(QZSettings::garmin_domain, QZSettings::default_garmin_domain).toString();
qDebug() << "GarminConnect: Loaded Garmin domain from settings:" << m_domain;
if (!m_oauth2Token.access_token.isEmpty()) {
qDebug() << "GarminConnect: Loaded tokens from settings (OAuth1 + OAuth2)";

View File

@@ -176,6 +176,7 @@ private:
static constexpr const char* SSO_URL_PATH = "/sso/signin";
static constexpr const char* SSO_EMBED_PATH = "/sso/embed";
static constexpr const char* OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
static constexpr bool DEBUG_GARMIN_VERBOSE = false; // Set to true for detailed response logging (may contain sensitive data)
// Private methods
QString ssoUrl() const { return QString("https://sso.%1").arg(m_domain); }

View File

@@ -6726,6 +6726,29 @@ import Qt.labs.platform 1.1
}
}
RowLayout {
spacing: 10
Label {
text: qsTr("Garmin Server:")
Layout.fillWidth: true
}
ComboBox {
id: garminServerComboBox
Layout.fillHeight: false
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
model: ["Global (garmin.com)", "China (garmin.cn)"]
currentIndex: settings.garmin_domain === "garmin.cn" ? 1 : 0
onCurrentIndexChanged: {
var newDomain = currentIndex === 1 ? "garmin.cn" : "garmin.com";
if (newDomain !== settings.garmin_domain) {
rootItem.garmin_connect_logout();
settings.garmin_domain = newDomain;
window.settings_restart_to_apply = true;
}
}
}
}
Button {
text: "Test Garmin Login"
Layout.alignment: Qt.AlignHCenter