diff --git a/src/gui/optionsdialog.cpp b/src/gui/optionsdialog.cpp index 6dafbbe8b1eb..b70aa95787ab 100644 --- a/src/gui/optionsdialog.cpp +++ b/src/gui/optionsdialog.cpp @@ -122,12 +122,17 @@ namespace } }; - bool isValidWebUIUsername(const QString &username) + bool isValidWebUIUsernameLength(const QString &username) { return (username.length() >= WEBUI_MIN_USERNAME_LENGTH); } - bool isValidWebUIPassword(const QString &password) + bool isValidWebUIUsernameCharacterSet(const QString &username) + { + return !username.contains(u":"); + } + + bool isValidWebUIPasswordLength(const QString &password) { return (password.length() >= WEBUI_MIN_PASSWORD_LENGTH); } @@ -1444,9 +1449,9 @@ void OptionsDialog::saveWebUITabOptions() const pref->setWebUIBanDuration(std::chrono::seconds {m_ui->spinBanDuration->value()}); pref->setWebUISessionTimeout(m_ui->spinSessionTimeout->value()); // Authentication - if (const QString username = webUIUsername(); isValidWebUIUsername(username)) + if (const QString username = webUIUsername(); isValidWebUIUsernameLength(username) && isValidWebUIUsernameCharacterSet(username)) pref->setWebUIUsername(username); - if (const QString password = webUIPassword(); isValidWebUIPassword(password)) + if (const QString password = webUIPassword(); isValidWebUIPasswordLength(password)) pref->setWebUIPassword(Utils::Password::PBKDF2::generate(password)); pref->setWebUILocalAuthEnabled(!m_ui->checkBypassLocalAuth->isChecked()); pref->setWebUIAuthSubnetWhitelistEnabled(m_ui->checkBypassAuthSubnetWhitelist->isChecked()); @@ -2106,14 +2111,20 @@ QString OptionsDialog::webUIPassword() const bool OptionsDialog::webUIAuthenticationOk() { - if (!isValidWebUIUsername(webUIUsername())) + const QString username = webUIUsername(); + if (!isValidWebUIUsernameLength(username)) { QMessageBox::warning(this, tr("Length Error"), tr("The WebUI username must be at least 3 characters long.")); return false; } + if (!isValidWebUIUsernameCharacterSet(username)) + { + QMessageBox::warning(this, tr("Character Error"), tr("The WebUI username must not contain a colon.")); + return false; + } const bool dontChangePassword = webUIPassword().isEmpty() && !Preferences::instance()->getWebUIPassword().isEmpty(); - if (!isValidWebUIPassword(webUIPassword()) && !dontChangePassword) + if (!isValidWebUIPasswordLength(webUIPassword()) && !dontChangePassword) { QMessageBox::warning(this, tr("Length Error"), tr("The WebUI password must be at least 6 characters long.")); return false; diff --git a/src/webui/api/appcontroller.cpp b/src/webui/api/appcontroller.cpp index 7901be53e4da..b8dd0120a06a 100644 --- a/src/webui/api/appcontroller.cpp +++ b/src/webui/api/appcontroller.cpp @@ -894,9 +894,21 @@ void AppController::setPreferencesAction() pref->setWebUIHttpsKeyPath(Path(it.value().toString())); // Authentication if (hasKey(u"web_ui_username"_s)) - pref->setWebUIUsername(it.value().toString()); + { + const QString username = it.value().toString(); + if (username.length() < 3) + throw APIError(APIErrorType::BadParams, tr("WebUI username must be at least 3 characters long")); + if (username.contains(u":")) + throw APIError(APIErrorType::BadParams, tr("WebUI username cannot contain a colon")); + pref->setWebUIUsername(username); + } if (hasKey(u"web_ui_password"_s)) + { + const QString password = it.value().toString(); + if (password.length() < 6) + throw APIError(APIErrorType::BadParams, tr("WebUI password must be at least 6 characters long")); pref->setWebUIPassword(Utils::Password::PBKDF2::generate(it.value().toByteArray())); + } if (hasKey(u"bypass_local_auth"_s)) pref->setWebUILocalAuthEnabled(!it.value().toBool()); if (hasKey(u"bypass_auth_subnet_whitelist_enabled"_s)) diff --git a/src/webui/api/authcontroller.cpp b/src/webui/api/authcontroller.cpp index c2db0d5960c2..b393f7d4de8a 100644 --- a/src/webui/api/authcontroller.cpp +++ b/src/webui/api/authcontroller.cpp @@ -31,9 +31,6 @@ #include #include "base/global.h" -#include "base/logger.h" -#include "base/preferences.h" -#include "base/utils/password.h" #include "apierror.h" #include "isessionmanager.h" @@ -43,18 +40,6 @@ AuthController::AuthController(ISessionManager *sessionManager, IApplication *ap { } -void AuthController::setUsername(const QString &username) -{ - m_username = username; - setResult(QString()); -} - -void AuthController::setPasswordHash(const QByteArray &passwordHash) -{ - m_passwordHash = passwordHash; - setResult(QString()); -} - void AuthController::loginAction() { if (m_sessionManager->session()) @@ -63,37 +48,13 @@ void AuthController::loginAction() return; } - const QString clientAddr = m_sessionManager->clientId(); - const QString usernameFromWeb = params()[u"username"_s]; - const QString passwordFromWeb = params()[u"password"_s]; - - if (isBanned()) - { - LogMsg(tr("WebAPI login failure. Reason: IP has been banned, IP: %1, username: %2") - .arg(clientAddr, usernameFromWeb) - , Log::WARNING); - throw APIError(APIErrorType::AccessDenied - , tr("Your IP address has been banned after too many failed authentication attempts.")); - } - - const bool usernameEqual = Utils::Password::slowEquals(usernameFromWeb.toUtf8(), m_username.toUtf8()); - const bool passwordEqual = Utils::Password::PBKDF2::verify(m_passwordHash, passwordFromWeb); - - if (usernameEqual && passwordEqual) + if (m_sessionManager->validateCredentials(params()[u"username"_s], params()[u"password"_s])) { - m_clientFailedLogins.remove(clientAddr); - m_sessionManager->sessionStart(); setStatus(APIStatus::Ok); - LogMsg(tr("WebAPI login success. IP: %1").arg(clientAddr)); } else { - if (Preferences::instance()->getWebUIMaxAuthFailCount() > 0) - increaseFailedAttempts(); - LogMsg(tr("WebAPI login failure. Reason: invalid credentials, attempt count: %1, IP: %2, username: %3") - .arg(QString::number(failedAttemptsCount()), clientAddr, usernameFromWeb) - , Log::WARNING); throw APIError(APIErrorType::Unauthorized); } } @@ -103,39 +64,3 @@ void AuthController::logoutAction() m_sessionManager->sessionEnd(); setResult(QString()); } - -bool AuthController::isBanned() const -{ - const auto failedLoginIter = m_clientFailedLogins.constFind(m_sessionManager->clientId()); - if (failedLoginIter == m_clientFailedLogins.cend()) - return false; - - bool isBanned = (failedLoginIter->banTimer.remainingTime() >= 0); - if (isBanned && failedLoginIter->banTimer.hasExpired()) - { - m_clientFailedLogins.erase(failedLoginIter); - isBanned = false; - } - - return isBanned; -} - -int AuthController::failedAttemptsCount() const -{ - return m_clientFailedLogins.value(m_sessionManager->clientId()).failedAttemptsCount; -} - -void AuthController::increaseFailedAttempts() -{ - Q_ASSERT(Preferences::instance()->getWebUIMaxAuthFailCount() > 0); - - FailedLogin &failedLogin = m_clientFailedLogins[m_sessionManager->clientId()]; - ++failedLogin.failedAttemptsCount; - - if (failedLogin.failedAttemptsCount >= Preferences::instance()->getWebUIMaxAuthFailCount()) - { - // Max number of failed attempts reached - // Start ban period - failedLogin.banTimer.setRemainingTime(Preferences::instance()->getWebUIBanDuration()); - } -} diff --git a/src/webui/api/authcontroller.h b/src/webui/api/authcontroller.h index cdd7c4cfbcd5..9c99c540f1c0 100644 --- a/src/webui/api/authcontroller.h +++ b/src/webui/api/authcontroller.h @@ -28,11 +28,6 @@ #pragma once -#include -#include -#include -#include - #include "apicontroller.h" class QString; @@ -47,27 +42,10 @@ class AuthController : public APIController public: explicit AuthController(ISessionManager *sessionManager, IApplication *app, QObject *parent = nullptr); - void setUsername(const QString &username); - void setPasswordHash(const QByteArray &passwordHash); - private slots: void loginAction(); void logoutAction(); private: - bool isBanned() const; - int failedAttemptsCount() const; - void increaseFailedAttempts(); - ISessionManager *m_sessionManager = nullptr; - - QString m_username; - QByteArray m_passwordHash; - - struct FailedLogin - { - int failedAttemptsCount = 0; - QDeadlineTimer banTimer {-1}; - }; - mutable QHash m_clientFailedLogins; }; diff --git a/src/webui/api/isessionmanager.h b/src/webui/api/isessionmanager.h index 64396a7da9df..c4510e92687b 100644 --- a/src/webui/api/isessionmanager.h +++ b/src/webui/api/isessionmanager.h @@ -41,8 +41,8 @@ struct ISession struct ISessionManager { virtual ~ISessionManager() = default; - virtual QString clientId() const = 0; virtual ISession *session() = 0; virtual void sessionStart() = 0; virtual void sessionEnd() = 0; + virtual bool validateCredentials(const QString &username, const QString &password) const = 0; }; diff --git a/src/webui/webapplication.cpp b/src/webui/webapplication.cpp index c6de1586ed82..b4bc70658605 100644 --- a/src/webui/webapplication.cpp +++ b/src/webui/webapplication.cpp @@ -79,6 +79,9 @@ const QString PUBLIC_FOLDER = u"/public"_s; const QString PRIVATE_FOLDER = u"/private"_s; const QString INDEX_HTML = u"/index.html"_s; +const QString BASIC_AUTH = u"Basic"_s; +const QString BEARER_AUTH = u"Bearer"_s; + namespace { QStringMap parseCookie(const QStringView cookieStr) @@ -100,10 +103,10 @@ namespace return ret; } - QString parseAuthorizationHeader(const QString &authHeader) + QString parseAuthorizationHeader(const QString &authHeader, const QString &authType) { - if (authHeader.startsWith(u"Bearer ", Qt::CaseInsensitive)) - return authHeader.sliced(7).trimmed(); + if (authHeader.startsWith(authType + u" ", Qt::CaseInsensitive)) + return authHeader.sliced(authType.length() + 1).trimmed(); return {}; } @@ -293,12 +296,12 @@ const Http::Environment &WebApplication::env() const void WebApplication::setUsername(const QString &username) { - m_authController->setUsername(username); + m_username = username; } void WebApplication::setPasswordHash(const QByteArray &passwordHash) { - m_authController->setPasswordHash(passwordHash); + m_passwordHash = passwordHash; } void WebApplication::doProcessRequest(const bool isUsingApiKey) @@ -645,7 +648,7 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons try { - const bool isUsingApiKey = m_request.headers.contains(Http::HEADER_AUTHORIZATION); + const bool isUsingApiKey = !parseAuthorizationHeader(m_request.headers.value(Http::HEADER_AUTHORIZATION), BEARER_AUTH).isEmpty(); // block suspicious requests if ((!isUsingApiKey && m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request)) @@ -709,8 +712,22 @@ void WebApplication::sessionInitialize() } } - if (!m_currentSession && !isAuthNeeded()) + if (m_currentSession) + return; + + if (!isAuthNeeded()) + { sessionStart(); + return; + } + + const QString credentials = parseAuthorizationHeader(m_request.headers.value(Http::HEADER_AUTHORIZATION), BASIC_AUTH); + if (!credentials.isEmpty()) + { + if (!validateBasicAuth(credentials)) + throw UnauthorizedHTTPError(); + sessionStart(); + } } void WebApplication::setSessionCookie() @@ -740,7 +757,7 @@ void WebApplication::apiKeySessionInitialize() return; QString sessionId; - if (const QString submittedKey = parseAuthorizationHeader(m_request.headers.value(Http::HEADER_AUTHORIZATION)); + if (const QString submittedKey = parseAuthorizationHeader(m_request.headers.value(Http::HEADER_AUTHORIZATION), BEARER_AUTH); Utils::Password::slowEquals(submittedKey.toLatin1(), m_apiKey.toLatin1())) { sessionId = submittedKey; @@ -982,6 +999,87 @@ QHostAddress WebApplication::resolveClientAddress() const return m_env.clientAddress; } +bool WebApplication::validateCredentials(const QString &username, const QString &password) const +{ + const QString clientAddr = clientId(); + + if (isBanned()) + { + LogMsg(tr("WebAPI login failure. Reason: IP has been banned, IP: %1, username: %2") + .arg(clientAddr, username) + , Log::WARNING); + throw ForbiddenHTTPError(tr("Your IP address has been banned after too many failed authentication attempts.")); + } + + const auto *pref = Preferences::instance(); + const bool usernameEqual = Utils::Password::slowEquals(username.toUtf8(), pref->getWebUIUsername().toUtf8()); + const bool passwordEqual = Utils::Password::PBKDF2::verify(pref->getWebUIPassword(), password); + + if (usernameEqual && passwordEqual) + { + m_clientFailedLogins.remove(clientAddr); + LogMsg(tr("WebAPI login success. IP: %1").arg(clientAddr)); + return true; + } + + if (pref->getWebUIMaxAuthFailCount() > 0) + increaseFailedAttempts(); + + LogMsg(tr("WebAPI login failure. Reason: invalid credentials, attempt count: %1, IP: %2, username: %3") + .arg(QString::number(failedAttemptsCount()), clientAddr, username) + , Log::WARNING); + return false; +} + +bool WebApplication::validateBasicAuth(const QString &credentials) const +{ + const QString usernamePassword = QString::fromUtf8(QByteArray::fromBase64(credentials.toLatin1())); + if (const qsizetype idx = usernamePassword.indexOf(u':'); idx > 0) + { + const QString username = usernamePassword.first(idx); + const QString password = usernamePassword.sliced(idx + 1); + return validateCredentials(username, password); + } + + return false; +} + +bool WebApplication::isBanned() const +{ + const auto failedLoginIter = m_clientFailedLogins.constFind(clientId()); + if (failedLoginIter == m_clientFailedLogins.cend()) + return false; + + bool isBanned = (failedLoginIter->banTimer.remainingTime() >= 0); + if (isBanned && failedLoginIter->banTimer.hasExpired()) + { + m_clientFailedLogins.erase(failedLoginIter); + isBanned = false; + } + + return isBanned; +} + +int WebApplication::failedAttemptsCount() const +{ + return m_clientFailedLogins.value(clientId()).failedAttemptsCount; +} + +void WebApplication::increaseFailedAttempts() const +{ + Q_ASSERT(Preferences::instance()->getWebUIMaxAuthFailCount() > 0); + + FailedLogin &failedLogin = m_clientFailedLogins[clientId()]; + ++failedLogin.failedAttemptsCount; + + if (failedLogin.failedAttemptsCount >= Preferences::instance()->getWebUIMaxAuthFailCount()) + { + // Max number of failed attempts reached + // Start ban period + failedLogin.banTimer.setRemainingTime(Preferences::instance()->getWebUIBanDuration()); + } +} + // WebSession WebSession::WebSession(const QString &sid, IApplication *app) diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index a752a3225dca..5c15532b0c0f 100644 --- a/src/webui/webapplication.h +++ b/src/webui/webapplication.h @@ -111,7 +111,7 @@ class WebApplication final : public ApplicationComponent void setPasswordHash(const QByteArray &passwordHash); private: - QString clientId() const override; + QString clientId() const; WebSession *session() override; void sessionStart() override; void sessionStartImpl(const QString &sessionId, bool useCookie); @@ -139,6 +139,12 @@ class WebApplication final : public ApplicationComponent bool isCrossSiteRequest(const Http::Request &request) const; bool validateHostHeader(const QStringList &domains) const; + bool validateCredentials(const QString &username, const QString &password) const override; + bool validateBasicAuth(const QString &credentials) const; + bool isBanned() const; + int failedAttemptsCount() const; + void increaseFailedAttempts() const; + // reverse proxy QHostAddress resolveClientAddress() const; @@ -258,6 +264,8 @@ class WebApplication final : public ApplicationComponent std::chrono::seconds m_sessionTimeout = 0s; QString m_sessionCookieName; QString m_apiKey; + QString m_username; + QByteArray m_passwordHash; // security related QStringList m_domainList; @@ -275,4 +283,11 @@ class WebApplication final : public ApplicationComponent BitTorrent::TorrentCreationManager *m_torrentCreationManager = nullptr; ClientDataStorage *m_clientDataStorage = nullptr; + + struct FailedLogin + { + int failedAttemptsCount = 0; + QDeadlineTimer banTimer {-1}; + }; + mutable QHash m_clientFailedLogins; }; diff --git a/src/webui/www/private/scripts/cache.js b/src/webui/www/private/scripts/cache.js index 25abb4c33400..5c9785e90e20 100644 --- a/src/webui/www/private/scripts/cache.js +++ b/src/webui/www/private/scripts/cache.js @@ -108,9 +108,12 @@ window.qBittorrent.Cache ??= (() => { json: JSON.stringify(data) }) }) - .then((response) => { - if (!response.ok) - return; + .then(async (response) => { + if (!response.ok) { + const error = new Error(await response.text()); + error.name = "ServerError"; + throw error; + } this.#m_store = structuredClone(this.#m_store); for (const key in data) { diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index c9c4053769c5..0ab9053900ce 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -3060,6 +3060,10 @@ alert("QBT_TR(The WebUI username must be at least 3 characters long.)QBT_TR[CONTEXT=OptionsDialog]"); return; } + if (web_ui_username.includes(":")) { + alert("QBT_TR(The WebUI username must not contain a colon.)QBT_TR[CONTEXT=OptionsDialog]"); + return; + } const web_ui_password = document.getElementById("webui_password_text").value; if ((0 < web_ui_password.length) && (web_ui_password.length < 6)) { alert("QBT_TR(The WebUI password must be at least 6 characters long.)QBT_TR[CONTEXT=OptionsDialog]"); @@ -3231,7 +3235,10 @@ } else { // keep window open so user can reattempt saving - alert("QBT_TR(Unable to save preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]"); + if (error.name === "ServerError") + alert(`QBT_TR(Error:)QBT_TR[CONTEXT=HttpServer] ${error.message}`); + else + alert("QBT_TR(Unable to save preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]"); } }); };