diff --git a/WebAPI_Changelog.md b/WebAPI_Changelog.md index 19dafdedc..0ed5ba7a4 100644 --- a/WebAPI_Changelog.md +++ b/WebAPI_Changelog.md @@ -1,5 +1,9 @@ # WebAPI Changelog +## 2.14.1 +* [#23212](https://github.com/qbittorrent/qBittorrent/pull/23212) + * Add `app/rotateAPIKey` endpoint for generating, and rotating, the WebAPI API key + ## 2.14.0 * [#23202](https://github.com/qbittorrent/qBittorrent/pull/23202) * WebAPI responds with the error message "Endpoint does not exist" when the endpoint does not exist, to better differentiate from unrelated Not Found (i.e. 404) responses diff --git a/src/app/application.cpp b/src/app/application.cpp index 0cc5dda46..8240e23b6 100644 --- a/src/app/application.cpp +++ b/src/app/application.cpp @@ -957,7 +957,7 @@ int Application::exec() const auto *pref = Preferences::instance(); const QString tempPassword = pref->getWebUIPassword().isEmpty() - ? Utils::Password::generate() : QString(); + ? Utils::Password::generate(9) : QString(); m_webui = new WebUI(this, (!tempPassword.isEmpty() ? Utils::Password::PBKDF2::generate(tempPassword) : QByteArray())); connect(m_webui, &WebUI::error, this, [](const QString &message) { diff --git a/src/base/CMakeLists.txt b/src/base/CMakeLists.txt index fa4123251..cb5b3d951 100644 --- a/src/base/CMakeLists.txt +++ b/src/base/CMakeLists.txt @@ -103,6 +103,7 @@ add_library(qbt_base STATIC torrentfilter.h types.h unicodestrings.h + utils/apikey.h utils/bytearray.h utils/compare.h utils/datetime.h @@ -200,6 +201,7 @@ add_library(qbt_base STATIC torrentfileguard.cpp torrentfileswatcher.cpp torrentfilter.cpp + utils/apikey.cpp utils/bytearray.cpp utils/compare.cpp utils/datetime.cpp diff --git a/src/base/http/types.h b/src/base/http/types.h index 25bb04849..2700945eb 100644 --- a/src/base/http/types.h +++ b/src/base/http/types.h @@ -43,6 +43,7 @@ namespace Http inline const QString METHOD_GET = u"GET"_s; inline const QString METHOD_POST = u"POST"_s; + inline const QString HEADER_AUTHORIZATION = u"authorization"_s; inline const QString HEADER_CACHE_CONTROL = u"cache-control"_s; inline const QString HEADER_CONNECTION = u"connection"_s; inline const QString HEADER_CONTENT_DISPOSITION = u"content-disposition"_s; diff --git a/src/base/preferences.cpp b/src/base/preferences.cpp index df6424ab4..bf87e8bd7 100644 --- a/src/base/preferences.cpp +++ b/src/base/preferences.cpp @@ -898,6 +898,19 @@ void Preferences::setWebUIPassword(const QByteArray &password) setValue(u"Preferences/WebUI/Password_PBKDF2"_s, password); } +QString Preferences::getWebUIApiKey() const +{ + return value(u"Preferences/WebUI/APIKey"_s); +} + +void Preferences::setWebUIApiKey(const QString &apiKey) +{ + if (apiKey == getWebUIApiKey()) + return; + + setValue(u"Preferences/WebUI/APIKey"_s, apiKey); +} + int Preferences::getWebUIMaxAuthFailCount() const { return value(u"Preferences/WebUI/MaxAuthenticationFailCount"_s, 5); diff --git a/src/base/preferences.h b/src/base/preferences.h index a927c0e70..92bedbaf5 100644 --- a/src/base/preferences.h +++ b/src/base/preferences.h @@ -211,6 +211,8 @@ public: void setWebUIUsername(const QString &username); QByteArray getWebUIPassword() const; void setWebUIPassword(const QByteArray &password); + QString getWebUIApiKey() const; + void setWebUIApiKey(const QString &apiKey); int getWebUIMaxAuthFailCount() const; void setWebUIMaxAuthFailCount(int count); std::chrono::seconds getWebUIBanDuration() const; diff --git a/src/base/utils/apikey.cpp b/src/base/utils/apikey.cpp new file mode 100644 index 000000000..47fd94972 --- /dev/null +++ b/src/base/utils/apikey.cpp @@ -0,0 +1,50 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "apikey.h" + +#include + +#include "base/global.h" +#include "base/utils/password.h" + +namespace +{ + const int keyLength = 28; + const QString prefix = u"qbt_"_s; +} + +QString Utils::APIKey::generate() +{ + return prefix + Utils::Password::generate(keyLength); +} + +bool Utils::APIKey::isValid(const QString &key) +{ + return key.startsWith(prefix) && (key.length() == (prefix.length() + keyLength)); +} diff --git a/src/base/utils/apikey.h b/src/base/utils/apikey.h new file mode 100644 index 000000000..644196b0e --- /dev/null +++ b/src/base/utils/apikey.h @@ -0,0 +1,37 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +class QString; + +namespace Utils::APIKey +{ + QString generate(); + bool isValid(const QString &key); +} diff --git a/src/base/utils/password.cpp b/src/base/utils/password.cpp index 75019d9cc..b63d7c7a6 100644 --- a/src/base/utils/password.cpp +++ b/src/base/utils/password.cpp @@ -67,10 +67,11 @@ bool Utils::Password::slowEquals(const QByteArray &a, const QByteArray &b) return (diff == 0); } -QString Utils::Password::generate() +QString Utils::Password::generate(const int passwordLength) { + Q_ASSERT(passwordLength > 0); + const QString alphanum = u"23456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"_s; - const int passwordLength = 9; QString pass; pass.reserve(passwordLength); while (pass.length() < passwordLength) diff --git a/src/base/utils/password.h b/src/base/utils/password.h index b656731e9..bbbb4f930 100644 --- a/src/base/utils/password.h +++ b/src/base/utils/password.h @@ -38,7 +38,7 @@ namespace Utils::Password // Taken from https://crackstation.net/hashing-security.htm bool slowEquals(const QByteArray &a, const QByteArray &b); - QString generate(); + QString generate(int passwordLength); namespace PBKDF2 { diff --git a/src/gui/optionsdialog.cpp b/src/gui/optionsdialog.cpp index 8fd606dd2..5ea18fe06 100644 --- a/src/gui/optionsdialog.cpp +++ b/src/gui/optionsdialog.cpp @@ -36,6 +36,7 @@ #include #include +#include #include #include #include @@ -62,6 +63,7 @@ #include "base/rss/rss_session.h" #include "base/torrentfileguard.h" #include "base/torrentfileswatcher.h" +#include "base/utils/apikey.h" #include "base/utils/compare.h" #include "base/utils/io.h" #include "base/utils/misc.h" @@ -133,6 +135,15 @@ namespace return (password.length() >= WEBUI_MIN_PASSWORD_LENGTH); } + QString maskAPIKey(const QString &key) + { + Q_ASSERT(Utils::APIKey::isValid(key)); + if (!Utils::APIKey::isValid(key)) [[unlikely]] + return {}; + + return key.first(4) + QString((key.length() - 10), QChar(u'•')) + key.last(6); + } + // Shortcuts for frequently used signals that have more than one overload. They would require // type casts and that is why we declare required member pointer here instead. void (QComboBox::*qComboBoxCurrentIndexChanged)(int) = &QComboBox::currentIndexChanged; @@ -1342,6 +1353,25 @@ void OptionsDialog::loadWebUITabOptions() webUIHttpsCertChanged(pref->getWebUIHttpsCertificatePath()); webUIHttpsKeyChanged(pref->getWebUIHttpsKeyPath()); m_ui->textWebUIUsername->setText(pref->getWebUIUsername()); + + // API Key + if (const QString apiKey = pref->getWebUIApiKey(); Utils::APIKey::isValid(apiKey)) + { + m_currentAPIKey = apiKey; + m_ui->textWebUIAPIKey->setText(maskAPIKey(m_currentAPIKey)); + m_ui->textWebUIAPIKey->setEnabled(true); + m_ui->btnWebUIAPIKeyCopy->setEnabled(true); + m_ui->btnWebUIAPIKeyRotate->setToolTip(tr("Rotate API key")); + } + else + { + m_currentAPIKey.clear(); + m_ui->textWebUIAPIKey->clear(); + m_ui->textWebUIAPIKey->setEnabled(false); + m_ui->btnWebUIAPIKeyCopy->setEnabled(false); + m_ui->btnWebUIAPIKeyRotate->setToolTip(tr("Generate API key")); + } + m_ui->checkBypassLocalAuth->setChecked(!pref->isWebUILocalAuthEnabled()); m_ui->checkBypassAuthSubnetWhitelist->setChecked(pref->isWebUIAuthSubnetWhitelistEnabled()); m_ui->IPSubnetWhitelistButton->setEnabled(m_ui->checkBypassAuthSubnetWhitelist->isChecked()); @@ -1382,6 +1412,8 @@ void OptionsDialog::loadWebUITabOptions() connect(m_ui->textWebUIUsername, &QLineEdit::textChanged, this, &ThisType::enableApplyButton); connect(m_ui->textWebUIPassword, &QLineEdit::textChanged, this, &ThisType::enableApplyButton); + connect(m_ui->btnWebUIAPIKeyCopy, &QPushButton::clicked, this, &ThisType::onBtnWebUIAPIKeyCopy); + connect(m_ui->btnWebUIAPIKeyRotate, &QPushButton::clicked, this, &ThisType::onBtnWebUIAPIKeyRotate); connect(m_ui->checkBypassLocalAuth, &QAbstractButton::toggled, this, &ThisType::enableApplyButton); connect(m_ui->checkBypassAuthSubnetWhitelist, &QAbstractButton::toggled, this, &ThisType::enableApplyButton); @@ -1457,6 +1489,38 @@ void OptionsDialog::saveWebUITabOptions() const pref->setDynDNSUsername(m_ui->DNSUsernameTxt->text()); pref->setDynDNSPassword(m_ui->DNSPasswordTxt->text()); } + +void OptionsDialog::onBtnWebUIAPIKeyCopy() +{ + if (!m_currentAPIKey.isEmpty()) + QApplication::clipboard()->setText(m_currentAPIKey); +} + +void OptionsDialog::onBtnWebUIAPIKeyRotate() +{ + const QString title = m_currentAPIKey.isEmpty() + ? tr("Generate API key") + : tr("Rotate API key"); + const QString message = m_currentAPIKey.isEmpty() + ? tr("Generate an API key? This key can be used to interact with qBittorrent's API.") + : tr("Rotate this API key? The current key will immediately stop working and a new key will be generated."); + + const QMessageBox::StandardButton button = QMessageBox::question( + this, title, message, (QMessageBox::Yes | QMessageBox::No), QMessageBox::No); + + if (button == QMessageBox::Yes) + { + m_currentAPIKey = Utils::APIKey::generate(); + m_ui->textWebUIAPIKey->setText(maskAPIKey(m_currentAPIKey)); + m_ui->textWebUIAPIKey->setEnabled(true); + m_ui->btnWebUIAPIKeyCopy->setEnabled(true); + m_ui->btnWebUIAPIKeyRotate->setToolTip(tr("Rotate API key")); + + auto *preferences = Preferences::instance(); + preferences->setWebUIApiKey(m_currentAPIKey); + preferences->apply(); + } +} #endif // DISABLE_WEBUI void OptionsDialog::initializeLanguageCombo() diff --git a/src/gui/optionsdialog.h b/src/gui/optionsdialog.h index c1e29c1d6..c63d8e6c7 100644 --- a/src/gui/optionsdialog.h +++ b/src/gui/optionsdialog.h @@ -110,6 +110,8 @@ private slots: void webUIHttpsCertChanged(const Path &path); void webUIHttpsKeyChanged(const Path &path); void on_registerDNSBtn_clicked(); + void onBtnWebUIAPIKeyCopy(); + void onBtnWebUIAPIKeyRotate(); #endif private: @@ -210,4 +212,8 @@ private: AdvancedSettings *m_advancedSettings = nullptr; bool m_refreshingIpFilter = false; + +#ifndef DISABLE_WEBUI + QString m_currentAPIKey; +#endif }; diff --git a/src/gui/optionsdialog.ui b/src/gui/optionsdialog.ui index 912f22b1a..6a138971b 100644 --- a/src/gui/optionsdialog.ui +++ b/src/gui/optionsdialog.ui @@ -3737,35 +3737,134 @@ Specify an IPv4 or IPv6 address. You can specify "0.0.0.0" for any IPv - - - - - Username: - - - - - - - - - - Password: - - - - - - - QLineEdit::EchoMode::Password - - - Change current password - - - - + + + User + + + + + + Username: + + + + + + + + + + Password: + + + + + + + QLineEdit::EchoMode::Password + + + Change current password + + + + + + + + + + API Key + + + + + + Key: + + + + + + + + + false + + + true + + + Generate a key + + + + + + + false + + + + 0 + 0 + + + + + 32 + 32 + + + + Copy API key + + + + + + + :/icons/edit-copy.svg:/icons/edit-copy.svg + + + false + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + Generate API key + + + + + + + :/icons/view-refresh.svg:/icons/view-refresh.svg + + + false + + + + + + + diff --git a/src/webui/api/appcontroller.cpp b/src/webui/api/appcontroller.cpp index f7aa1b72d..014c9bfde 100644 --- a/src/webui/api/appcontroller.cpp +++ b/src/webui/api/appcontroller.cpp @@ -60,6 +60,7 @@ #include "base/rss/rss_session.h" #include "base/torrentfileguard.h" #include "base/torrentfileswatcher.h" +#include "base/utils/apikey.h" #include "base/utils/datetime.h" #include "base/utils/fs.h" #include "base/utils/misc.h" @@ -338,6 +339,8 @@ void AppController::preferencesAction() data[u"web_ui_max_auth_fail_count"_s] = pref->getWebUIMaxAuthFailCount(); data[u"web_ui_ban_duration"_s] = static_cast(pref->getWebUIBanDuration().count()); data[u"web_ui_session_timeout"_s] = pref->getWebUISessionTimeout(); + // API key + data[u"web_ui_api_key"_s] = pref->getWebUIApiKey(); // Use alternative WebUI data[u"alternative_webui_enabled"_s] = pref->isAltWebUIEnabled(); data[u"alternative_webui_path"_s] = pref->getWebUIRootFolder().toString(); @@ -1312,6 +1315,17 @@ void AppController::setCookiesAction() setResult(QString()); } +void AppController::rotateAPIKeyAction() +{ + const QString key = Utils::APIKey::generate(); + + auto *preferences = Preferences::instance(); + preferences->setWebUIApiKey(key); + preferences->apply(); + + setResult(QJsonObject {{u"apiKey"_s, key}}); +} + void AppController::networkInterfaceListAction() { QJsonArray ifaceList; diff --git a/src/webui/api/appcontroller.h b/src/webui/api/appcontroller.h index dea33eada..8426a474f 100644 --- a/src/webui/api/appcontroller.h +++ b/src/webui/api/appcontroller.h @@ -52,6 +52,7 @@ private slots: void getDirectoryContentAction(); void cookiesAction(); void setCookiesAction(); + void rotateAPIKeyAction(); void networkInterfaceListAction(); void networkInterfaceAddressListAction(); diff --git a/src/webui/webapplication.cpp b/src/webui/webapplication.cpp index f3ea9defe..1233b03d9 100644 --- a/src/webui/webapplication.cpp +++ b/src/webui/webapplication.cpp @@ -52,9 +52,11 @@ #include "base/logger.h" #include "base/preferences.h" #include "base/types.h" +#include "base/utils/apikey.h" #include "base/utils/fs.h" #include "base/utils/io.h" #include "base/utils/misc.h" +#include "base/utils/password.h" #include "base/utils/random.h" #include "base/utils/string.h" #include "api/apierror.h" @@ -99,6 +101,14 @@ namespace return ret; } + QString parseAuthorizationHeader(const QString &authHeader) + { + if (authHeader.startsWith(u"Bearer ", Qt::CaseInsensitive)) + return authHeader.sliced(7).trimmed(); + + return {}; + } + QUrl urlFromHostHeader(const QString &hostHeader) { if (!hostHeader.contains(u"://")) @@ -292,11 +302,14 @@ void WebApplication::setPasswordHash(const QByteArray &passwordHash) m_authController->setPasswordHash(passwordHash); } -void WebApplication::doProcessRequest() +void WebApplication::doProcessRequest(const bool isUsingApiKey) { const QRegularExpressionMatch match = m_apiPathPattern.match(request().path); if (!match.hasMatch()) { + if (isUsingApiKey) + throw NotFoundHTTPError(); + sendWebUIFile(); return; } @@ -315,9 +328,16 @@ void WebApplication::doProcessRequest() if (!controller) { if (scope == u"auth") + { + if (isUsingApiKey) + throw ForbiddenHTTPError(); + controller = m_authController; + } else + { throw NotFoundHTTPError(); + } } // Filter HTTP methods @@ -529,6 +549,9 @@ void WebApplication::configure() if (m_trustedReverseProxyList.isEmpty()) m_isReverseProxySupportEnabled = false; } + + if (const QString apiKey = pref->getWebUIApiKey(); apiKey.isEmpty() || Utils::APIKey::isValid(apiKey)) + m_apiKey = apiKey; } void WebApplication::declarePublicAPI(const QString &apiPath) @@ -619,8 +642,10 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons try { + const bool isUsingApiKey = m_request.headers.contains(Http::HEADER_AUTHORIZATION); + // block suspicious requests - if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request)) + if ((!isUsingApiKey && m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request)) || (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList))) { throw UnauthorizedHTTPError(); @@ -629,8 +654,12 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons // reverse proxy resolve client address m_clientAddress = resolveClientAddress(); - sessionInitialize(); - doProcessRequest(); + if (isUsingApiKey) + apiKeySessionInitialize(); + else + sessionInitialize(); + + doProcessRequest(isUsingApiKey); } catch (const HTTPError &error) { @@ -683,6 +712,31 @@ void WebApplication::sessionInitialize() sessionStart(); } +void WebApplication::apiKeySessionInitialize() +{ + Q_ASSERT(!m_currentSession); + + if (m_apiKey.isEmpty()) + return; + + QString sessionId; + if (const QString submittedKey = parseAuthorizationHeader(m_request.headers.value(Http::HEADER_AUTHORIZATION)); + Utils::Password::slowEquals(submittedKey.toLatin1(), m_apiKey.toLatin1())) + { + sessionId = submittedKey; + } + + if (!sessionId.isEmpty()) + { + m_currentSession = m_sessions.value(sessionId); + // api key sessions don't "expire" since there's no point in triggering re-auth + if (m_currentSession) + m_currentSession->updateTimestamp(); + else + sessionStartImpl(sessionId, false); + } +} + QString WebApplication::generateSid() const { QString sid; @@ -714,6 +768,11 @@ bool WebApplication::isPublicAPI(const QString &scope, const QString &action) co } void WebApplication::sessionStart() +{ + sessionStartImpl(generateSid(), true); +} + +void WebApplication::sessionStartImpl(const QString &sessionId, const bool useCookie) { Q_ASSERT(!m_currentSession); @@ -729,7 +788,7 @@ void WebApplication::sessionStart() return false; }); - m_currentSession = new WebSession(generateSid(), app()); + m_currentSession = new WebSession(sessionId, app()); m_sessions[m_currentSession->id()] = m_currentSession; m_currentSession->registerAPIController(u"app"_s, new AppController(app(), m_currentSession)); @@ -747,15 +806,18 @@ void WebApplication::sessionStart() connect(btSession, &BitTorrent::Session::freeDiskSpaceChecked, syncController, &SyncController::updateFreeDiskSpace); m_currentSession->registerAPIController(u"sync"_s, syncController); - QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()}; - cookie.setHttpOnly(true); - cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute - cookie.setPath(u"/"_s); - if (m_isCSRFProtectionEnabled) - cookie.setSameSitePolicy(QNetworkCookie::SameSite::Strict); - else if (cookie.isSecure()) - cookie.setSameSitePolicy(QNetworkCookie::SameSite::None); - setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())}); + if (useCookie) + { + QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()}; + cookie.setHttpOnly(true); + cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute + cookie.setPath(u"/"_s); + if (m_isCSRFProtectionEnabled) + cookie.setSameSitePolicy(QNetworkCookie::SameSite::Strict); + else if (cookie.isSecure()) + cookie.setSameSitePolicy(QNetworkCookie::SameSite::None); + setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())}); + } } void WebApplication::sessionEnd() diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index 41201caa9..16554342a 100644 --- a/src/webui/webapplication.h +++ b/src/webui/webapplication.h @@ -107,9 +107,10 @@ private: QString clientId() const override; WebSession *session() override; void sessionStart() override; + void sessionStartImpl(const QString &sessionId, bool useCookie); void sessionEnd() override; - void doProcessRequest(); + void doProcessRequest(bool isUsingApiKey); void configure(); void declarePublicAPI(const QString &apiPath); @@ -122,6 +123,7 @@ private: // Session management QString generateSid() const; void sessionInitialize(); + void apiKeySessionInitialize(); bool isAuthNeeded(); bool isPublicAPI(const QString &scope, const QString &action) const; @@ -148,6 +150,7 @@ private: const QHash, QString> m_allowedMethod = { // <, HTTP method> + {{u"app"_s, u"rotateAPIKey"_s}, Http::METHOD_POST}, {{u"app"_s, u"sendTestEmail"_s}, Http::METHOD_POST}, {{u"app"_s, u"setCookies"_s}, Http::METHOD_POST}, {{u"app"_s, u"setPreferences"_s}, Http::METHOD_POST}, @@ -245,6 +248,7 @@ private: QList m_authSubnetWhitelist; int m_sessionTimeout = 0; QString m_sessionCookieName; + QString m_apiKey; // security related QStringList m_domainList; diff --git a/src/webui/www/private/views/confirmRotateAPIKey.html b/src/webui/www/private/views/confirmRotateAPIKey.html new file mode 100644 index 000000000..44df72593 --- /dev/null +++ b/src/webui/www/private/views/confirmRotateAPIKey.html @@ -0,0 +1,49 @@ +
+
+ + +
+
+
+ + +
+ + diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index 7325197cf..67ccfc239 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -967,26 +967,55 @@
QBT_TR(Authentication)QBT_TR[CONTEXT=OptionsDialog] - - - - - - - - - - - -
- - - -
- - - -
+
+ QBT_TR(User)QBT_TR[CONTEXT=OptionsDialog] + + + + + + + + + + + +
+ + + +
+ + + +
+
+
+ QBT_TR(API Key)QBT_TR[CONTEXT=OptionsDialog] + + + + + + + + + +
+ + + + + + + +
+
@@ -1793,6 +1822,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD updateDynDnsSettings: updateDynDnsSettings, updateWebuiLocaleSelect: updateWebuiLocaleSelect, registerDynDns: registerDynDns, + rotateAPIKey: rotateAPIKey, applyPreferences: applyPreferences }; }; @@ -2549,6 +2579,15 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD document.getElementById("webUIBanDurationInput").value = Number(pref.web_ui_ban_duration); document.getElementById("webUISessionTimeoutInput").value = Number(pref.web_ui_session_timeout); + // API key + if (pref.web_ui_api_key.length > 0) { + document.getElementById("WebUIAPIKeyText").value = maskAPIKey(pref.web_ui_api_key); + document.getElementById("WebUIAPIKeyText").dataset.apiKey = pref.web_ui_api_key; + document.getElementById("webUIAPIKeyCopyButton").disabled = false; + document.getElementById("webUIAPIKeyRotateButton").dataset.hasKey = "true"; + document.querySelector("#webUIAPIKeyRotateButton img").title = "QBT_TR(Rotate API key)QBT_TR[CONTEXT=OptionsDialog]"; + } + // Use alternative WebUI document.getElementById("use_alt_webui_checkbox").checked = pref.alternative_webui_enabled; document.getElementById("webui_files_location_textarea").value = pref.alternative_webui_path; @@ -3227,6 +3266,70 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD window.qBittorrent.pathAutofill.attachPathAutofill(); }; + // show static prefix plus 6 sensitive characters + const maskAPIKey = (key) => (key.slice(0, 4) + "•".repeat(key.length - 10) + key.slice(key.length - 6)); + + const rotateAPIKey = () => { + fetch("api/v2/app/rotateAPIKey", { + method: "POST", + }) + .then(async (response) => { + if (!response.ok) { + alert(await response.text()); + return; + } + + const { apiKey } = await response.json(); + const apiKeyTextElem = document.getElementById("WebUIAPIKeyText"); + apiKeyTextElem.value = maskAPIKey(apiKey); + apiKeyTextElem.dataset.apiKey = apiKey; + + document.getElementById("webUIAPIKeyCopyButton").disabled = false; + document.getElementById("webUIAPIKeyRotateButton").dataset.hasKey = "true"; + document.querySelector("#webUIAPIKeyRotateButton img").title = "QBT_TR(Rotate API key)QBT_TR[CONTEXT=OptionsDialog]"; + }) + .catch((error) => { + alert(`QBT_TR(Unable to rotate API key.)QBT_TR[CONTEXT=HttpServer] ${error.toString()}`); + }); + }; + + document.getElementById("webUIAPIKeyCopyButton").addEventListener("click", async (e) => { + const apiKey = document.getElementById("WebUIAPIKeyText").dataset.apiKey; + await clipboardCopy(apiKey); + + const copyImg = document.querySelector("#webUIAPIKeyCopyButton img.copyImg"); + const checkImg = document.querySelector("#webUIAPIKeyCopyButton img.checkImg"); + + copyImg.style.display = "none"; + checkImg.style.display = "inline"; + + setTimeout(() => { + copyImg.style.display = "inline"; + checkImg.style.display = "none"; + }, 2000); + }); + + document.getElementById("webUIAPIKeyRotateButton").addEventListener("click", (e) => { + const hasKey = e.target.parentElement.dataset.hasKey; + const title = hasKey + ? "QBT_TR(Rotate API key)QBT_TR[CONTEXT=OptionsDialog]" + : "QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]"; + const message = hasKey + ? "QBT_TR(Rotate this API key? The current key will immediately stop working and a new key will be generated.)QBT_TR[CONTEXT=confirmRotateAPIKeyDialog]" + : "QBT_TR(Generate an API key? This key can be used to interact with qBittorrent's API.)QBT_TR[CONTEXT=confirmRotateAPIKeyDialog]"; + + new MochaUI.Modal({ + ...window.qBittorrent.Dialog.baseModalOptions, + id: "confirmRotateAPIKeyDialog", + title: title, + contentURL: "views/confirmRotateAPIKey.html?v=${CACHEID}", + data: { + hasKey: hasKey, + message: message, + }, + }); + }); + return exports(); })(); Object.freeze(window.qBittorrent.Preferences); diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index f17da4d50..95e832924 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -424,6 +424,7 @@ private/views/confirmAutoTMM.html private/views/confirmdeletion.html private/views/confirmRecheck.html + private/views/confirmRotateAPIKey.html private/views/cookies.html private/views/createtorrent.html private/views/filters.html