From 9b00972b4d864340b03edcc88cadd0439dc04d00 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:16:26 +0100 Subject: [PATCH] [Server] Serve password reset from the database interface (#7842) cmdForgotPasswordRequest, continuePasswordRequest, cmdForgotPasswordReset, cmdForgotPasswordChallenge, cmdRequestPasswordSalt and cmdSubmitPasswordVerifier still called Servatrice_DatabaseInterface, and two of them validated the account token and email through the generic validateTableColumnStringData helper. Add doesForgotPasswordExist, addForgotPassword and getUsableLegacyBackup to Server_DatabaseInterface with fail-closed defaults, replace the table/column probes with userTokenMatches and userEmailMatches using the same statement and case-insensitive comparison, and let the commands ask the injected interface. Audit records, check order and every response code are unchanged. --- .../server/remote/server_database_interface.h | 25 ++++++++++++++++ .../src/servatrice_database_interface.cpp | 10 +++++++ .../src/servatrice_database_interface.h | 8 +++-- servatrice/src/serversocketinterface.cpp | 29 +++++++++---------- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index 41daf31d0..098a40535 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -404,6 +404,31 @@ public: { return false; } + /** @brief Whether an unexpired forgot-password request exists for the user. */ + virtual bool doesForgotPasswordExist(const QString & /* userName */) + { + return false; + } + /** @brief Records a forgot-password request for the user. */ + virtual bool addForgotPassword(const QString & /* userName */) + { + return false; + } + /** @brief The sealed legacy password backup of a user, empty when none is usable. */ + virtual QString getUsableLegacyBackup(const QString & /* userName */) + { + return {}; + } + /** @brief Whether the user's stored token matches, case-insensitively. */ + virtual bool userTokenMatches(const QString & /* userName */, const QString & /* token */) + { + return false; + } + /** @brief Whether the user's stored email matches, case-insensitively. */ + virtual bool userEmailMatches(const QString & /* userName */, const QString & /* email */) + { + return false; + } virtual qint64 startSession(const QString & /* userName */, const QString & /* address */, const QString & /* clientId */, diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 4c9501eb1..a0b967cd1 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -3552,6 +3552,16 @@ bool Servatrice_DatabaseInterface::validateTableColumnStringData(const QString & return false; } +bool Servatrice_DatabaseInterface::userTokenMatches(const QString &userName, const QString &token) +{ + return validateTableColumnStringData("{prefix}_users", "token", userName, token); +} + +bool Servatrice_DatabaseInterface::userEmailMatches(const QString &userName, const QString &email) +{ + return validateTableColumnStringData("{prefix}_users", "email", userName, email); +} + void Servatrice_DatabaseInterface::addAuditRecord(const QString &user, const QString &ipaddress, const QString &clientid, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 9bb90645a..1d9e53279 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -87,7 +87,7 @@ public: /** @brief Decrypt the account's legacy password backup when it exists, is decryptable * with the configured key and has not aged out; purges it when it has. Returns the * legacy credential in the stored "salt + hash" format, or an empty string. */ - QString getUsableLegacyBackup(const QString &user); + QString getUsableLegacyBackup(const QString &user) override; /** @brief Refresh the age window of the account's legacy password backup. */ void touchLegacyBackup(const QString &user); /** @brief Drop the account's legacy password backup. */ @@ -273,9 +273,11 @@ public: RemoveUserBannerResult removeUserBanner(const QString &userName); int clearBlacklistedBanners(); bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override; - bool addForgotPassword(const QString &user); + bool addForgotPassword(const QString &user) override; bool removeForgotPassword(const QString &user) override; - bool doesForgotPasswordExist(const QString &user); + bool doesForgotPasswordExist(const QString &user) override; + bool userTokenMatches(const QString &userName, const QString &token) override; + bool userEmailMatches(const QString &userName, const QString &email) override; bool updateUserToken(const QString &token, const QString &user); bool validateTableColumnStringData(const QString &table, const QString &column, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index ca5bb8597..788a6f7c7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3637,7 +3637,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordRequest(c return Response::RespOk; } - if (!sqlInterface->userExists(userName)) { + if (!databaseInterface->userExists(userName)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET_REQUEST", "User does not exist", false); @@ -3654,7 +3654,7 @@ Response::ResponseCode AbstractServerSocketInterface::continuePasswordRequest(co ResponseContainer &rc, bool challenged) { - if (sqlInterface->doesForgotPasswordExist(userName)) { + if (databaseInterface->doesForgotPasswordExist(userName)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), @@ -3669,7 +3669,7 @@ Response::ResponseCode AbstractServerSocketInterface::continuePasswordRequest(co QString banReason; int banTimeRemaining; - if (sqlInterface->checkUserIsBanned(this->getAddress(), userName, clientId, banReason, banTimeRemaining)) { + if (databaseInterface->checkUserIsBanned(this->getAddress(), userName, clientId, banReason, banTimeRemaining)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET_REQUEST", "User is banned", false); @@ -3678,7 +3678,7 @@ Response::ResponseCode AbstractServerSocketInterface::continuePasswordRequest(co return Response::RespFunctionNotAllowed; } - if (!sqlInterface->addForgotPassword(userName)) { + if (!databaseInterface->addForgotPassword(userName)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET_REQUEST", "Failed to create password reset", false); @@ -3707,7 +3707,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con QString clientId = nameFromStdString(cmd.clientid()); qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password reset from user:" << userName; - if (!sqlInterface->doesForgotPasswordExist(userName)) { + if (!databaseInterface->doesForgotPasswordExist(userName)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET", "Request does not exist for user", false); @@ -3716,8 +3716,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con return Response::RespFunctionNotAllowed; } - if (!sqlInterface->validateTableColumnStringData("{prefix}_users", "token", userName, - nameFromStdString(cmd.token()))) { + if (!databaseInterface->userTokenMatches(userName, nameFromStdString(cmd.token()))) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), userName.simplified(), "PASSWORD_RESET", "Failed token validation", false); @@ -3745,14 +3744,14 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con return Response::RespClientUpdateRequired; } - if (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) { + if (databaseInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET", "", true); } - sqlInterface->setForcePasswordChange(nameFromStdString(cmd.user_name()), false); - sqlInterface->removeForgotPassword(nameFromStdString(cmd.user_name())); + databaseInterface->setForcePasswordChange(nameFromStdString(cmd.user_name()), false); + databaseInterface->removeForgotPassword(nameFromStdString(cmd.user_name())); return Response::RespOk; } @@ -3785,7 +3784,7 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa return Response::RespFunctionNotAllowed; } - if (!sqlInterface->userExists(userName)) { + if (!databaseInterface->userExists(userName)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET_CHALLENGE", "User does not exist", false); @@ -3794,7 +3793,7 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa } const auto parsedEmailAddress = EmailParser::getParsedEmailAddress(nameFromStdString(cmd.email())); - if (!sqlInterface->validateTableColumnStringData("{prefix}_users", "email", userName, parsedEmailAddress)) { + if (!databaseInterface->userEmailMatches(userName, parsedEmailAddress)) { if (servatrice->getEnableForgotPasswordAudit()) { databaseInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), "PASSWORD_RESET_CHALLENGE", "Failed to answer email challenge question", @@ -3816,7 +3815,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con // Simplified exactly like cmdLogin, so the nonce bound to userName here validates // against the same normalized name during the login's challenge-response check. const QString userName = nameFromStdString(cmd.user_name()).simplified(); - const QString storedPasswordData = sqlInterface->getUserPasswordData(userName); + const QString storedPasswordData = databaseInterface->getUserPasswordData(userName); if (storedPasswordData.isEmpty()) { if (server->getRegOnlyServerEnabled()) { return Response::RespRegistrationRequired; @@ -3855,7 +3854,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con // encrypted legacy backup exists, serve its salt there so they can still log in; // challenge-response clients must use scrypt_salt, which always carries the // verifier salt. - const QString legacyBackup = sqlInterface->getUsableLegacyBackup(userName); + const QString legacyBackup = databaseInterface->getUsableLegacyBackup(userName); if (!legacyBackup.isEmpty()) { re->set_password_salt(legacyBackup.left(16).toStdString()); } @@ -3986,7 +3985,7 @@ AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPas return Response::RespContextError; } - if (!sqlInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) { + if (!databaseInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) { return Response::RespContextError; }