mirror of
https://github.com/qbittorrent/qBittorrent.git
synced 2025-12-17 06:01:33 -06:00
WebAPI: Add support for authenticating via API key
PR #23212. Closes #13201.
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# WebAPI Changelog
|
# 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
|
## 2.14.0
|
||||||
* [#23202](https://github.com/qbittorrent/qBittorrent/pull/23202)
|
* [#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
|
* 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
|
||||||
|
|||||||
@@ -957,7 +957,7 @@ int Application::exec()
|
|||||||
const auto *pref = Preferences::instance();
|
const auto *pref = Preferences::instance();
|
||||||
|
|
||||||
const QString tempPassword = pref->getWebUIPassword().isEmpty()
|
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()));
|
m_webui = new WebUI(this, (!tempPassword.isEmpty() ? Utils::Password::PBKDF2::generate(tempPassword) : QByteArray()));
|
||||||
connect(m_webui, &WebUI::error, this, [](const QString &message)
|
connect(m_webui, &WebUI::error, this, [](const QString &message)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ add_library(qbt_base STATIC
|
|||||||
torrentfilter.h
|
torrentfilter.h
|
||||||
types.h
|
types.h
|
||||||
unicodestrings.h
|
unicodestrings.h
|
||||||
|
utils/apikey.h
|
||||||
utils/bytearray.h
|
utils/bytearray.h
|
||||||
utils/compare.h
|
utils/compare.h
|
||||||
utils/datetime.h
|
utils/datetime.h
|
||||||
@@ -200,6 +201,7 @@ add_library(qbt_base STATIC
|
|||||||
torrentfileguard.cpp
|
torrentfileguard.cpp
|
||||||
torrentfileswatcher.cpp
|
torrentfileswatcher.cpp
|
||||||
torrentfilter.cpp
|
torrentfilter.cpp
|
||||||
|
utils/apikey.cpp
|
||||||
utils/bytearray.cpp
|
utils/bytearray.cpp
|
||||||
utils/compare.cpp
|
utils/compare.cpp
|
||||||
utils/datetime.cpp
|
utils/datetime.cpp
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ namespace Http
|
|||||||
inline const QString METHOD_GET = u"GET"_s;
|
inline const QString METHOD_GET = u"GET"_s;
|
||||||
inline const QString METHOD_POST = u"POST"_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_CACHE_CONTROL = u"cache-control"_s;
|
||||||
inline const QString HEADER_CONNECTION = u"connection"_s;
|
inline const QString HEADER_CONNECTION = u"connection"_s;
|
||||||
inline const QString HEADER_CONTENT_DISPOSITION = u"content-disposition"_s;
|
inline const QString HEADER_CONTENT_DISPOSITION = u"content-disposition"_s;
|
||||||
|
|||||||
@@ -898,6 +898,19 @@ void Preferences::setWebUIPassword(const QByteArray &password)
|
|||||||
setValue(u"Preferences/WebUI/Password_PBKDF2"_s, 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
|
int Preferences::getWebUIMaxAuthFailCount() const
|
||||||
{
|
{
|
||||||
return value<int>(u"Preferences/WebUI/MaxAuthenticationFailCount"_s, 5);
|
return value<int>(u"Preferences/WebUI/MaxAuthenticationFailCount"_s, 5);
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ public:
|
|||||||
void setWebUIUsername(const QString &username);
|
void setWebUIUsername(const QString &username);
|
||||||
QByteArray getWebUIPassword() const;
|
QByteArray getWebUIPassword() const;
|
||||||
void setWebUIPassword(const QByteArray &password);
|
void setWebUIPassword(const QByteArray &password);
|
||||||
|
QString getWebUIApiKey() const;
|
||||||
|
void setWebUIApiKey(const QString &apiKey);
|
||||||
int getWebUIMaxAuthFailCount() const;
|
int getWebUIMaxAuthFailCount() const;
|
||||||
void setWebUIMaxAuthFailCount(int count);
|
void setWebUIMaxAuthFailCount(int count);
|
||||||
std::chrono::seconds getWebUIBanDuration() const;
|
std::chrono::seconds getWebUIBanDuration() const;
|
||||||
|
|||||||
50
src/base/utils/apikey.cpp
Normal file
50
src/base/utils/apikey.cpp
Normal 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
37
src/base/utils/apikey.h
Normal 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);
|
||||||
|
}
|
||||||
@@ -67,10 +67,11 @@ bool Utils::Password::slowEquals(const QByteArray &a, const QByteArray &b)
|
|||||||
return (diff == 0);
|
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 QString alphanum = u"23456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"_s;
|
||||||
const int passwordLength = 9;
|
|
||||||
QString pass;
|
QString pass;
|
||||||
pass.reserve(passwordLength);
|
pass.reserve(passwordLength);
|
||||||
while (pass.length() < passwordLength)
|
while (pass.length() < passwordLength)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ namespace Utils::Password
|
|||||||
// Taken from https://crackstation.net/hashing-security.htm
|
// Taken from https://crackstation.net/hashing-security.htm
|
||||||
bool slowEquals(const QByteArray &a, const QByteArray &b);
|
bool slowEquals(const QByteArray &a, const QByteArray &b);
|
||||||
|
|
||||||
QString generate();
|
QString generate(int passwordLength);
|
||||||
|
|
||||||
namespace PBKDF2
|
namespace PBKDF2
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
#include "base/rss/rss_session.h"
|
#include "base/rss/rss_session.h"
|
||||||
#include "base/torrentfileguard.h"
|
#include "base/torrentfileguard.h"
|
||||||
#include "base/torrentfileswatcher.h"
|
#include "base/torrentfileswatcher.h"
|
||||||
|
#include "base/utils/apikey.h"
|
||||||
#include "base/utils/compare.h"
|
#include "base/utils/compare.h"
|
||||||
#include "base/utils/io.h"
|
#include "base/utils/io.h"
|
||||||
#include "base/utils/misc.h"
|
#include "base/utils/misc.h"
|
||||||
@@ -133,6 +135,15 @@ namespace
|
|||||||
return (password.length() >= WEBUI_MIN_PASSWORD_LENGTH);
|
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
|
// 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.
|
// type casts and that is why we declare required member pointer here instead.
|
||||||
void (QComboBox::*qComboBoxCurrentIndexChanged)(int) = &QComboBox::currentIndexChanged;
|
void (QComboBox::*qComboBoxCurrentIndexChanged)(int) = &QComboBox::currentIndexChanged;
|
||||||
@@ -1342,6 +1353,25 @@ void OptionsDialog::loadWebUITabOptions()
|
|||||||
webUIHttpsCertChanged(pref->getWebUIHttpsCertificatePath());
|
webUIHttpsCertChanged(pref->getWebUIHttpsCertificatePath());
|
||||||
webUIHttpsKeyChanged(pref->getWebUIHttpsKeyPath());
|
webUIHttpsKeyChanged(pref->getWebUIHttpsKeyPath());
|
||||||
m_ui->textWebUIUsername->setText(pref->getWebUIUsername());
|
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->checkBypassLocalAuth->setChecked(!pref->isWebUILocalAuthEnabled());
|
||||||
m_ui->checkBypassAuthSubnetWhitelist->setChecked(pref->isWebUIAuthSubnetWhitelistEnabled());
|
m_ui->checkBypassAuthSubnetWhitelist->setChecked(pref->isWebUIAuthSubnetWhitelistEnabled());
|
||||||
m_ui->IPSubnetWhitelistButton->setEnabled(m_ui->checkBypassAuthSubnetWhitelist->isChecked());
|
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->textWebUIUsername, &QLineEdit::textChanged, this, &ThisType::enableApplyButton);
|
||||||
connect(m_ui->textWebUIPassword, &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->checkBypassLocalAuth, &QAbstractButton::toggled, this, &ThisType::enableApplyButton);
|
||||||
connect(m_ui->checkBypassAuthSubnetWhitelist, &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->setDynDNSUsername(m_ui->DNSUsernameTxt->text());
|
||||||
pref->setDynDNSPassword(m_ui->DNSPasswordTxt->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
|
#endif // DISABLE_WEBUI
|
||||||
|
|
||||||
void OptionsDialog::initializeLanguageCombo()
|
void OptionsDialog::initializeLanguageCombo()
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ private slots:
|
|||||||
void webUIHttpsCertChanged(const Path &path);
|
void webUIHttpsCertChanged(const Path &path);
|
||||||
void webUIHttpsKeyChanged(const Path &path);
|
void webUIHttpsKeyChanged(const Path &path);
|
||||||
void on_registerDNSBtn_clicked();
|
void on_registerDNSBtn_clicked();
|
||||||
|
void onBtnWebUIAPIKeyCopy();
|
||||||
|
void onBtnWebUIAPIKeyRotate();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -210,4 +212,8 @@ private:
|
|||||||
AdvancedSettings *m_advancedSettings = nullptr;
|
AdvancedSettings *m_advancedSettings = nullptr;
|
||||||
|
|
||||||
bool m_refreshingIpFilter = false;
|
bool m_refreshingIpFilter = false;
|
||||||
|
|
||||||
|
#ifndef DISABLE_WEBUI
|
||||||
|
QString m_currentAPIKey;
|
||||||
|
#endif
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3737,7 +3737,11 @@ Specify an IPv4 or IPv6 address. You can specify "0.0.0.0" for any IPv
|
|||||||
</property>
|
</property>
|
||||||
<layout class="QVBoxLayout" name="verticalLayout_35">
|
<layout class="QVBoxLayout" name="verticalLayout_35">
|
||||||
<item>
|
<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">
|
<item row="0" column="0">
|
||||||
<widget class="QLabel" name="lblWebUIUsername">
|
<widget class="QLabel" name="lblWebUIUsername">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
@@ -3766,6 +3770,101 @@ Specify an IPv4 or IPv6 address. You can specify "0.0.0.0" for any IPv
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</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>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QCheckBox" name="checkBypassLocalAuth">
|
<widget class="QCheckBox" name="checkBypassLocalAuth">
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
#include "base/rss/rss_session.h"
|
#include "base/rss/rss_session.h"
|
||||||
#include "base/torrentfileguard.h"
|
#include "base/torrentfileguard.h"
|
||||||
#include "base/torrentfileswatcher.h"
|
#include "base/torrentfileswatcher.h"
|
||||||
|
#include "base/utils/apikey.h"
|
||||||
#include "base/utils/datetime.h"
|
#include "base/utils/datetime.h"
|
||||||
#include "base/utils/fs.h"
|
#include "base/utils/fs.h"
|
||||||
#include "base/utils/misc.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_max_auth_fail_count"_s] = pref->getWebUIMaxAuthFailCount();
|
||||||
data[u"web_ui_ban_duration"_s] = static_cast<int>(pref->getWebUIBanDuration().count());
|
data[u"web_ui_ban_duration"_s] = static_cast<int>(pref->getWebUIBanDuration().count());
|
||||||
data[u"web_ui_session_timeout"_s] = pref->getWebUISessionTimeout();
|
data[u"web_ui_session_timeout"_s] = pref->getWebUISessionTimeout();
|
||||||
|
// API key
|
||||||
|
data[u"web_ui_api_key"_s] = pref->getWebUIApiKey();
|
||||||
// Use alternative WebUI
|
// Use alternative WebUI
|
||||||
data[u"alternative_webui_enabled"_s] = pref->isAltWebUIEnabled();
|
data[u"alternative_webui_enabled"_s] = pref->isAltWebUIEnabled();
|
||||||
data[u"alternative_webui_path"_s] = pref->getWebUIRootFolder().toString();
|
data[u"alternative_webui_path"_s] = pref->getWebUIRootFolder().toString();
|
||||||
@@ -1312,6 +1315,17 @@ void AppController::setCookiesAction()
|
|||||||
setResult(QString());
|
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()
|
void AppController::networkInterfaceListAction()
|
||||||
{
|
{
|
||||||
QJsonArray ifaceList;
|
QJsonArray ifaceList;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ private slots:
|
|||||||
void getDirectoryContentAction();
|
void getDirectoryContentAction();
|
||||||
void cookiesAction();
|
void cookiesAction();
|
||||||
void setCookiesAction();
|
void setCookiesAction();
|
||||||
|
void rotateAPIKeyAction();
|
||||||
|
|
||||||
void networkInterfaceListAction();
|
void networkInterfaceListAction();
|
||||||
void networkInterfaceAddressListAction();
|
void networkInterfaceAddressListAction();
|
||||||
|
|||||||
@@ -52,9 +52,11 @@
|
|||||||
#include "base/logger.h"
|
#include "base/logger.h"
|
||||||
#include "base/preferences.h"
|
#include "base/preferences.h"
|
||||||
#include "base/types.h"
|
#include "base/types.h"
|
||||||
|
#include "base/utils/apikey.h"
|
||||||
#include "base/utils/fs.h"
|
#include "base/utils/fs.h"
|
||||||
#include "base/utils/io.h"
|
#include "base/utils/io.h"
|
||||||
#include "base/utils/misc.h"
|
#include "base/utils/misc.h"
|
||||||
|
#include "base/utils/password.h"
|
||||||
#include "base/utils/random.h"
|
#include "base/utils/random.h"
|
||||||
#include "base/utils/string.h"
|
#include "base/utils/string.h"
|
||||||
#include "api/apierror.h"
|
#include "api/apierror.h"
|
||||||
@@ -99,6 +101,14 @@ namespace
|
|||||||
return ret;
|
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)
|
QUrl urlFromHostHeader(const QString &hostHeader)
|
||||||
{
|
{
|
||||||
if (!hostHeader.contains(u"://"))
|
if (!hostHeader.contains(u"://"))
|
||||||
@@ -292,11 +302,14 @@ void WebApplication::setPasswordHash(const QByteArray &passwordHash)
|
|||||||
m_authController->setPasswordHash(passwordHash);
|
m_authController->setPasswordHash(passwordHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebApplication::doProcessRequest()
|
void WebApplication::doProcessRequest(const bool isUsingApiKey)
|
||||||
{
|
{
|
||||||
const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
|
const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
|
||||||
if (!match.hasMatch())
|
if (!match.hasMatch())
|
||||||
{
|
{
|
||||||
|
if (isUsingApiKey)
|
||||||
|
throw NotFoundHTTPError();
|
||||||
|
|
||||||
sendWebUIFile();
|
sendWebUIFile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -315,10 +328,17 @@ void WebApplication::doProcessRequest()
|
|||||||
if (!controller)
|
if (!controller)
|
||||||
{
|
{
|
||||||
if (scope == u"auth")
|
if (scope == u"auth")
|
||||||
|
{
|
||||||
|
if (isUsingApiKey)
|
||||||
|
throw ForbiddenHTTPError();
|
||||||
|
|
||||||
controller = m_authController;
|
controller = m_authController;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
throw NotFoundHTTPError();
|
throw NotFoundHTTPError();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter HTTP methods
|
// Filter HTTP methods
|
||||||
const auto allowedMethodIter = m_allowedMethod.constFind({scope, action});
|
const auto allowedMethodIter = m_allowedMethod.constFind({scope, action});
|
||||||
@@ -529,6 +549,9 @@ void WebApplication::configure()
|
|||||||
if (m_trustedReverseProxyList.isEmpty())
|
if (m_trustedReverseProxyList.isEmpty())
|
||||||
m_isReverseProxySupportEnabled = false;
|
m_isReverseProxySupportEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (const QString apiKey = pref->getWebUIApiKey(); apiKey.isEmpty() || Utils::APIKey::isValid(apiKey))
|
||||||
|
m_apiKey = apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebApplication::declarePublicAPI(const QString &apiPath)
|
void WebApplication::declarePublicAPI(const QString &apiPath)
|
||||||
@@ -619,8 +642,10 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
const bool isUsingApiKey = m_request.headers.contains(Http::HEADER_AUTHORIZATION);
|
||||||
|
|
||||||
// block suspicious requests
|
// block suspicious requests
|
||||||
if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
|
if ((!isUsingApiKey && m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
|
||||||
|| (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList)))
|
|| (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList)))
|
||||||
{
|
{
|
||||||
throw UnauthorizedHTTPError();
|
throw UnauthorizedHTTPError();
|
||||||
@@ -629,8 +654,12 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons
|
|||||||
// reverse proxy resolve client address
|
// reverse proxy resolve client address
|
||||||
m_clientAddress = resolveClientAddress();
|
m_clientAddress = resolveClientAddress();
|
||||||
|
|
||||||
|
if (isUsingApiKey)
|
||||||
|
apiKeySessionInitialize();
|
||||||
|
else
|
||||||
sessionInitialize();
|
sessionInitialize();
|
||||||
doProcessRequest();
|
|
||||||
|
doProcessRequest(isUsingApiKey);
|
||||||
}
|
}
|
||||||
catch (const HTTPError &error)
|
catch (const HTTPError &error)
|
||||||
{
|
{
|
||||||
@@ -683,6 +712,31 @@ void WebApplication::sessionInitialize()
|
|||||||
sessionStart();
|
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 WebApplication::generateSid() const
|
||||||
{
|
{
|
||||||
QString sid;
|
QString sid;
|
||||||
@@ -714,6 +768,11 @@ bool WebApplication::isPublicAPI(const QString &scope, const QString &action) co
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WebApplication::sessionStart()
|
void WebApplication::sessionStart()
|
||||||
|
{
|
||||||
|
sessionStartImpl(generateSid(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApplication::sessionStartImpl(const QString &sessionId, const bool useCookie)
|
||||||
{
|
{
|
||||||
Q_ASSERT(!m_currentSession);
|
Q_ASSERT(!m_currentSession);
|
||||||
|
|
||||||
@@ -729,7 +788,7 @@ void WebApplication::sessionStart()
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
m_currentSession = new WebSession(generateSid(), app());
|
m_currentSession = new WebSession(sessionId, app());
|
||||||
m_sessions[m_currentSession->id()] = m_currentSession;
|
m_sessions[m_currentSession->id()] = m_currentSession;
|
||||||
|
|
||||||
m_currentSession->registerAPIController(u"app"_s, new AppController(app(), 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);
|
connect(btSession, &BitTorrent::Session::freeDiskSpaceChecked, syncController, &SyncController::updateFreeDiskSpace);
|
||||||
m_currentSession->registerAPIController(u"sync"_s, syncController);
|
m_currentSession->registerAPIController(u"sync"_s, syncController);
|
||||||
|
|
||||||
|
if (useCookie)
|
||||||
|
{
|
||||||
QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()};
|
QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()};
|
||||||
cookie.setHttpOnly(true);
|
cookie.setHttpOnly(true);
|
||||||
cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute
|
cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute
|
||||||
@@ -756,6 +817,7 @@ void WebApplication::sessionStart()
|
|||||||
else if (cookie.isSecure())
|
else if (cookie.isSecure())
|
||||||
cookie.setSameSitePolicy(QNetworkCookie::SameSite::None);
|
cookie.setSameSitePolicy(QNetworkCookie::SameSite::None);
|
||||||
setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
|
setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebApplication::sessionEnd()
|
void WebApplication::sessionEnd()
|
||||||
|
|||||||
@@ -107,9 +107,10 @@ private:
|
|||||||
QString clientId() const override;
|
QString clientId() const override;
|
||||||
WebSession *session() override;
|
WebSession *session() override;
|
||||||
void sessionStart() override;
|
void sessionStart() override;
|
||||||
|
void sessionStartImpl(const QString &sessionId, bool useCookie);
|
||||||
void sessionEnd() override;
|
void sessionEnd() override;
|
||||||
|
|
||||||
void doProcessRequest();
|
void doProcessRequest(bool isUsingApiKey);
|
||||||
void configure();
|
void configure();
|
||||||
|
|
||||||
void declarePublicAPI(const QString &apiPath);
|
void declarePublicAPI(const QString &apiPath);
|
||||||
@@ -122,6 +123,7 @@ private:
|
|||||||
// Session management
|
// Session management
|
||||||
QString generateSid() const;
|
QString generateSid() const;
|
||||||
void sessionInitialize();
|
void sessionInitialize();
|
||||||
|
void apiKeySessionInitialize();
|
||||||
bool isAuthNeeded();
|
bool isAuthNeeded();
|
||||||
bool isPublicAPI(const QString &scope, const QString &action) const;
|
bool isPublicAPI(const QString &scope, const QString &action) const;
|
||||||
|
|
||||||
@@ -148,6 +150,7 @@ private:
|
|||||||
const QHash<std::pair<QString, QString>, QString> m_allowedMethod =
|
const QHash<std::pair<QString, QString>, QString> m_allowedMethod =
|
||||||
{
|
{
|
||||||
// <<controller name, action name>, HTTP method>
|
// <<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"sendTestEmail"_s}, Http::METHOD_POST},
|
||||||
{{u"app"_s, u"setCookies"_s}, Http::METHOD_POST},
|
{{u"app"_s, u"setCookies"_s}, Http::METHOD_POST},
|
||||||
{{u"app"_s, u"setPreferences"_s}, Http::METHOD_POST},
|
{{u"app"_s, u"setPreferences"_s}, Http::METHOD_POST},
|
||||||
@@ -245,6 +248,7 @@ private:
|
|||||||
QList<Utils::Net::Subnet> m_authSubnetWhitelist;
|
QList<Utils::Net::Subnet> m_authSubnetWhitelist;
|
||||||
int m_sessionTimeout = 0;
|
int m_sessionTimeout = 0;
|
||||||
QString m_sessionCookieName;
|
QString m_sessionCookieName;
|
||||||
|
QString m_apiKey;
|
||||||
|
|
||||||
// security related
|
// security related
|
||||||
QStringList m_domainList;
|
QStringList m_domainList;
|
||||||
|
|||||||
49
src/webui/www/private/views/confirmRotateAPIKey.html
Normal file
49
src/webui/www/private/views/confirmRotateAPIKey.html
Normal 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>
|
||||||
@@ -967,6 +967,8 @@
|
|||||||
|
|
||||||
<fieldset class="settings">
|
<fieldset class="settings">
|
||||||
<legend>QBT_TR(Authentication)QBT_TR[CONTEXT=OptionsDialog]</legend>
|
<legend>QBT_TR(Authentication)QBT_TR[CONTEXT=OptionsDialog]</legend>
|
||||||
|
<fieldset class="settings">
|
||||||
|
<legend>QBT_TR(User)QBT_TR[CONTEXT=OptionsDialog]</legend>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -987,6 +989,33 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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">
|
<div class="formRow">
|
||||||
<input type="checkbox" id="bypass_local_auth_checkbox">
|
<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>
|
<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,
|
updateDynDnsSettings: updateDynDnsSettings,
|
||||||
updateWebuiLocaleSelect: updateWebuiLocaleSelect,
|
updateWebuiLocaleSelect: updateWebuiLocaleSelect,
|
||||||
registerDynDns: registerDynDns,
|
registerDynDns: registerDynDns,
|
||||||
|
rotateAPIKey: rotateAPIKey,
|
||||||
applyPreferences: applyPreferences
|
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("webUIBanDurationInput").value = Number(pref.web_ui_ban_duration);
|
||||||
document.getElementById("webUISessionTimeoutInput").value = Number(pref.web_ui_session_timeout);
|
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
|
// Use alternative WebUI
|
||||||
document.getElementById("use_alt_webui_checkbox").checked = pref.alternative_webui_enabled;
|
document.getElementById("use_alt_webui_checkbox").checked = pref.alternative_webui_enabled;
|
||||||
document.getElementById("webui_files_location_textarea").value = pref.alternative_webui_path;
|
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();
|
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();
|
return exports();
|
||||||
})();
|
})();
|
||||||
Object.freeze(window.qBittorrent.Preferences);
|
Object.freeze(window.qBittorrent.Preferences);
|
||||||
|
|||||||
@@ -424,6 +424,7 @@
|
|||||||
<file>private/views/confirmAutoTMM.html</file>
|
<file>private/views/confirmAutoTMM.html</file>
|
||||||
<file>private/views/confirmdeletion.html</file>
|
<file>private/views/confirmdeletion.html</file>
|
||||||
<file>private/views/confirmRecheck.html</file>
|
<file>private/views/confirmRecheck.html</file>
|
||||||
|
<file>private/views/confirmRotateAPIKey.html</file>
|
||||||
<file>private/views/cookies.html</file>
|
<file>private/views/cookies.html</file>
|
||||||
<file>private/views/createtorrent.html</file>
|
<file>private/views/createtorrent.html</file>
|
||||||
<file>private/views/filters.html</file>
|
<file>private/views/filters.html</file>
|
||||||
|
|||||||
Reference in New Issue
Block a user