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

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

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,26 +967,55 @@
<fieldset class="settings">
<legend>QBT_TR(Authentication)QBT_TR[CONTEXT=OptionsDialog]</legend>
<table>
<tbody>
<tr>
<td>
<label for="webui_username_text">QBT_TR(Username:)QBT_TR[CONTEXT=OptionsDialog]</label>
</td>
<td>
<input type="text" id="webui_username_text">
</td>
</tr>
<tr>
<td>
<label for="webui_password_text">QBT_TR(Password:)QBT_TR[CONTEXT=OptionsDialog]</label>
</td>
<td>
<input type="password" id="webui_password_text" placeholder="QBT_TR(Change current password)QBT_TR[CONTEXT=OptionsDialog]" autocomplete="new-password">
</td>
</tr>
</tbody>
</table>
<fieldset class="settings">
<legend>QBT_TR(User)QBT_TR[CONTEXT=OptionsDialog]</legend>
<table>
<tbody>
<tr>
<td>
<label for="webui_username_text">QBT_TR(Username:)QBT_TR[CONTEXT=OptionsDialog]</label>
</td>
<td>
<input type="text" id="webui_username_text">
</td>
</tr>
<tr>
<td>
<label for="webui_password_text">QBT_TR(Password:)QBT_TR[CONTEXT=OptionsDialog]</label>
</td>
<td>
<input type="password" id="webui_password_text" placeholder="QBT_TR(Change current password)QBT_TR[CONTEXT=OptionsDialog]" autocomplete="new-password">
</td>
</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>