[Server/Client] Enforce and complete the forced password change at login (#7680)

checkUserPassword ignored force_password_change on the challenge-response
path, so a reset password stayed valid. Returning PasswordChangeRequired
was not enough on its own: the session was dropped and the client told the
user to ask the administrator for the temporary password they had just used.

The pending session now keeps its identity without registering a user
session or announcing a join, may only ping and change its password, and
the client completes the change in-session before reconnecting.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-11-02 07:01:29 +01:00 • committed by GitHub
parent ef4574d2b9
commit 50a2ae9cc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 575 additions and 28 deletions

View file

@ -15,7 +15,7 @@ target_include_directories(libcockatrice_network_client_abstract PUBLIC .)
target_link_libraries(
libcockatrice_network_client_abstract
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
libcockatrice_network_server_remote
libcockatrice_network_server_remote libcockatrice_utility
)
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -10,6 +10,7 @@
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/protocol/pb/session_commands.pb.h"
#include "libcockatrice/protocol/pb/session_event.pb.h"
#include "libcockatrice/utility/passwordhasher.h"
#include <QDebug>
#include <QList>
@ -80,6 +81,26 @@ AbstractClient::~AbstractClient()
{
}
Command_AccountPassword AbstractClient::makeAccountPasswordCommand(const QString &oldPassword,
const QString &newPassword) const
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (getServerSupportsChallengeResponse()) {
// A verifier lets the server upgrade the stored credential without ever seeing the
// password, and it is what a challenge-response server expects for a new password.
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
} else if (getServerSupportsPasswordHash()) {
// The server hashes the password itself with its own salt; a salted hash is the
// strongest form it accepts, so the plaintext never goes on the wire.
const QString passwordSalt = PasswordHasher::generateRandomSalt();
cmd.set_hashed_new_password(PasswordHasher::computeHash(newPassword, passwordSalt).toStdString());
} else {
cmd.set_new_password(newPassword.toStdString());
}
return cmd;
}
void AbstractClient::processProtocolItem(const ServerMessage &item)
{
switch (item.message_type()) {

View file

@ -47,6 +47,7 @@ class Event_UserJoined;
class Event_UserLeft;
class Event_ServerMessage;
class Event_ListRooms;
class Command_AccountPassword;
class Event_GameJoined;
class Event_UserMessage;
class Event_NotifyUser;
@ -67,6 +68,9 @@ enum ClientStatus
StatusSubmitForgotPasswordReset,
StatusSubmitForgotPasswordChallenge,
StatusGettingPasswordSalt,
//! The server accepted the credentials but keeps the session open until the account
//! password is replaced; the connection must survive for `Command_AccountPassword`.
StatusPasswordChangeRequired,
};
class AbstractClient : public QObject
@ -171,6 +175,16 @@ public:
{
return serverSupportsChallengeResponse;
}
/**
* @brief Builds the account-password command in the form this server understands.
*
* Challenge-response servers take a scrypt verifier; servers that only advertise
* SupportsPasswordHash take a salted SHA-512 hash; anything older gets the plaintext,
* which is the only form it understands. Both password-change paths go through here so
* the two cannot drift apart and hand a plaintext password to a server that can hash it.
*/
Command_AccountPassword makeAccountPasswordCommand(const QString &oldPassword, const QString &newPassword) const;
const QString &getUserName() const
{
return userName;