WebAPI: Add support for authenticating via API key

PR #23212.
Closes #13201.
This commit is contained in:
Tom Piccirello
2025-10-16 04:50:59 -07:00
committed by GitHub
parent bb97817f35
commit 312e914adb
20 changed files with 581 additions and 68 deletions

View File

@@ -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

View File

@@ -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)
{

View File

@@ -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

View File

@@ -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;

View File

@@ -898,6 +898,19 @@ void Preferences::setWebUIPassword(const QByteArray &password)
setValue(u"Preferences/WebUI/Password_PBKDF2"_s, password);
}
QString Preferences::getWebUIApiKey() const
{
return value<QString>(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<int>(u"Preferences/WebUI/MaxAuthenticationFailCount"_s, 5);

View File

@@ -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;

50
src/base/utils/apikey.cpp Normal file
View File

@@ -0,0 +1,50 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 Thomas Piccirello <thomas@piccirello.com>
*
* 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 <QString>
#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));
}

37
src/base/utils/apikey.h Normal file
View File

@@ -0,0 +1,37 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 Thomas Piccirello <thomas@piccirello.com>
*
* 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);
}

View File

@@ -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)

View File

@@ -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
{

View File

@@ -36,6 +36,7 @@
#include <limits>
#include <QApplication>
#include <QClipboard>
#include <QDebug>
#include <QDesktopServices>
#include <QDialogButtonBox>
@@ -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()

View File

@@ -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
};

View File

@@ -3737,7 +3737,11 @@ Specify an IPv4 or IPv6 address. You can specify &quot;0.0.0.0&quot; for any IPv
</property>
<layout class="QVBoxLayout" name="verticalLayout_35">
<item>
<layout class="QGridLayout" name="gridLayout_8">
<widget class="QGroupBox" name="groupWebUIUser">
<property name="title">
<string>User</string>
</property>
<layout class="QGridLayout" name="gridLayoutUser">
<item row="0" column="0">
<widget class="QLabel" name="lblWebUIUsername">
<property name="text">
@@ -3766,6 +3770,101 @@ Specify an IPv4 or IPv6 address. You can specify &quot;0.0.0.0&quot; for any IPv
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupWebUIAPIKey">
<property name="title">
<string>API Key</string>
</property>
<layout class="QGridLayout" name="gridLayoutAPIKey">
<item row="0" column="0">
<widget class="QLabel" name="lblWebUIAPIKey">
<property name="text">
<string>Key:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="horizontalLayoutAPIKey">
<item>
<widget class="QLineEdit" name="textWebUIAPIKey">
<property name="enabled">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
<property name="placeholderText">
<string>Generate a key</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnWebUIAPIKeyCopy">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="toolTip">
<string>Copy API key</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../icons.qrc">
<normaloff>:/icons/edit-copy.svg</normaloff>:/icons/edit-copy.svg</iconset>
</property>
<property name="flat">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnWebUIAPIKeyRotate">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="toolTip">
<string>Generate API key</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../icons.qrc">
<normaloff>:/icons/view-refresh.svg</normaloff>:/icons/view-refresh.svg</iconset>
</property>
<property name="flat">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBypassLocalAuth">

View File

@@ -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<int>(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;

View File

@@ -52,6 +52,7 @@ private slots:
void getDirectoryContentAction();
void cookiesAction();
void setCookiesAction();
void rotateAPIKeyAction();
void networkInterfaceListAction();
void networkInterfaceAddressListAction();

View File

@@ -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,10 +328,17 @@ void WebApplication::doProcessRequest()
if (!controller)
{
if (scope == u"auth")
{
if (isUsingApiKey)
throw ForbiddenHTTPError();
controller = m_authController;
}
else
{
throw NotFoundHTTPError();
}
}
// Filter HTTP methods
const auto allowedMethodIter = m_allowedMethod.constFind({scope, action});
@@ -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();
if (isUsingApiKey)
apiKeySessionInitialize();
else
sessionInitialize();
doProcessRequest();
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,6 +806,8 @@ void WebApplication::sessionStart()
connect(btSession, &BitTorrent::Session::freeDiskSpaceChecked, syncController, &SyncController::updateFreeDiskSpace);
m_currentSession->registerAPIController(u"sync"_s, syncController);
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
@@ -756,6 +817,7 @@ void WebApplication::sessionStart()
else if (cookie.isSecure())
cookie.setSameSitePolicy(QNetworkCookie::SameSite::None);
setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
}
}
void WebApplication::sessionEnd()

View File

@@ -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<std::pair<QString, QString>, QString> m_allowedMethod =
{
// <<controller name, action name>, 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<Utils::Net::Subnet> m_authSubnetWhitelist;
int m_sessionTimeout = 0;
QString m_sessionCookieName;
QString m_apiKey;
// security related
QStringList m_domainList;

View File

@@ -0,0 +1,49 @@
<div id="confirmRotateAPIKeyDialog">
<div class="genericConfirmGrid">
<span class="confirmGridItem confirmWarning"></span>
<span class="confirmGridItem dialogMessage"></span>
</div>
</div>
<div>
<input type="button" value="QBT_TR(Yes)QBT_TR[CONTEXT=MainWindow]" id="confirmRotateButton">
<input type="button" value="QBT_TR(No)QBT_TR[CONTEXT=MainWindow]" id="cancelRotateButton">
</div>
<script>
"use strict";
(() => {
const { windowEl, options } = window.MUI.Windows.instances["confirmRotateAPIKeyDialog"];
const { message } = options.data;
const confirmButton = document.getElementById("confirmRotateButton");
const cancelButton = document.getElementById("cancelRotateButton");
const dialog = document.getElementById("confirmRotateAPIKeyDialog");
dialog.querySelector("span.dialogMessage").textContent = message;
cancelButton.addEventListener("click", (e) => {
window.qBittorrent.Client.closeWindow(dialog);
});
confirmButton.addEventListener("click", (e) => {
window.qBittorrent.Preferences.rotateAPIKey();
window.qBittorrent.Client.closeWindow(dialog);
});
// set tabindex so window element receives keydown events
windowEl.setAttribute("tabindex", "-1");
windowEl.focus();
windowEl.addEventListener("keydown", (e) => {
switch (e.key) {
case "Enter":
e.stopPropagation();
confirmButton.click();
break;
case "Escape":
e.stopPropagation();
window.qBittorrent.Client.closeWindow(dialog);
break;
}
});
})();
</script>

View File

@@ -967,6 +967,8 @@
<fieldset class="settings">
<legend>QBT_TR(Authentication)QBT_TR[CONTEXT=OptionsDialog]</legend>
<fieldset class="settings">
<legend>QBT_TR(User)QBT_TR[CONTEXT=OptionsDialog]</legend>
<table>
<tbody>
<tr>
@@ -987,6 +989,33 @@
</tr>
</tbody>
</table>
</fieldset>
<fieldset class="settings">
<legend>QBT_TR(API Key)QBT_TR[CONTEXT=OptionsDialog]</legend>
<table>
<tbody>
<tr>
<td>
<label for="WebUIAPIKeyText">QBT_TR(Key:)QBT_TR[CONTEXT=OptionsDialog]</label>
</td>
<td>
<input type="text" disabled id="WebUIAPIKeyText" placeholder="QBT_TR(Generate a key)QBT_TR[CONTEXT=OptionsDialog]" style="width: 200px;">
</td>
<td>
<button type="button" disabled id="webUIAPIKeyCopyButton" style="padding: 0;" aria-label="QBT_TR(Copy API key)QBT_TR[CONTEXT=OptionsDialog]">
<img class="copyImg" src="images/edit-copy.svg" alt="QBT_TR(Copy API key)QBT_TR[CONTEXT=OptionsDialog]" title="QBT_TR(Copy API key)QBT_TR[CONTEXT=OptionsDialog]" width="16" height="16" style="margin: 4px; top: 2px; position: relative;">
<img class="checkImg" src="images/checked-completed.svg" alt="QBT_TR(Copied)QBT_TR[CONTEXT=OptionsDialog]" title="QBT_TR(Copied)QBT_TR[CONTEXT=OptionsDialog]" width="16" height="16" style="margin: 4px; top: 2px; position: relative; display: none;">
</button>
</td>
<td>
<button type="button" id="webUIAPIKeyRotateButton" style="padding: 0;" data-has-key="false" aria-label="QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]">
<img src="images/force-recheck.svg" alt="QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]" title="QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]" width="16" height="16" style="margin: 4px; top: 2px; position: relative;">
</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
<div class="formRow">
<input type="checkbox" id="bypass_local_auth_checkbox">
<label for="bypass_local_auth_checkbox">QBT_TR(Bypass authentication for clients on localhost)QBT_TR[CONTEXT=OptionsDialog]</label>
@@ -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);

View File

@@ -424,6 +424,7 @@
<file>private/views/confirmAutoTMM.html</file>
<file>private/views/confirmdeletion.html</file>
<file>private/views/confirmRecheck.html</file>
<file>private/views/confirmRotateAPIKey.html</file>
<file>private/views/cookies.html</file>
<file>private/views/createtorrent.html</file>
<file>private/views/filters.html</file>