From b382f9fabbbefabd57d1e2816376f2b0b536e653 Mon Sep 17 00:00:00 2001 From: 9001-Sols <9001.sols@gmail.com> Date: Tue, 10 Feb 2026 10:58:26 -0500 Subject: [PATCH] Trust Tokens --- sql/accounts_trust_tokens.sql | 8 ++ src/login/auth_session.cpp | 252 +++++++++++++++++++--------------- src/login/auth_session.h | 3 +- src/login/otp_helpers.h | 88 +++++++++++- 4 files changed, 235 insertions(+), 116 deletions(-) create mode 100644 sql/accounts_trust_tokens.sql diff --git a/sql/accounts_trust_tokens.sql b/sql/accounts_trust_tokens.sql new file mode 100644 index 00000000000..fec67f0db87 --- /dev/null +++ b/sql/accounts_trust_tokens.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `accounts_trust_tokens` ( + `token` CHAR(64) NOT NULL, + `accid` INT UNSIGNED NOT NULL, + `expires` DATETIME NOT NULL, + PRIMARY KEY (`token`), + INDEX `idx_accid` (`accid`), + CONSTRAINT `fk_trust_accid` FOREIGN KEY (`accid`) REFERENCES `accounts`(`id`) ON DELETE CASCADE +); diff --git a/src/login/auth_session.cpp b/src/login/auth_session.cpp index ee251b37eb1..12c961a090a 100644 --- a/src/login/auth_session.cpp +++ b/src/login/auth_session.cpp @@ -108,7 +108,7 @@ void auth_session::read_func() do_write(jsonStringSize); }; - const auto sendLoginResult = [&](const login_result errorCode, const uint8 len) + const auto sendLoginResult = [&](const login_result errorCode) { json loginErrorCodeReply; loginErrorCodeReply["result"] = errorCode; // "old style" backwards compatible error code @@ -146,12 +146,14 @@ void auth_session::read_func() return; } - int8 code = loginHelpers::jsonGet(jsonBuffer, "command").value_or(0); - std::string username = loginHelpers::jsonGet(jsonBuffer, "username").value_or(""); - std::string password = loginHelpers::jsonGet(jsonBuffer, "password").value_or(""); - std::string updated_password = loginHelpers::jsonGet(jsonBuffer, "new_password").value_or(""); - std::string otp = loginHelpers::jsonGet(jsonBuffer, "otp").value_or(""); - std::array version = loginHelpers::jsonGet(jsonBuffer, "version").value_or(std::array{ 0, 0, 0 }); + int8 code = loginHelpers::jsonGet(jsonBuffer, "command").value_or(0); + std::string username = loginHelpers::jsonGet(jsonBuffer, "username").value_or(""); + std::string password = loginHelpers::jsonGet(jsonBuffer, "password").value_or(""); + std::string updated_password = loginHelpers::jsonGet(jsonBuffer, "new_password").value_or(""); + std::string otp = loginHelpers::jsonGet(jsonBuffer, "otp").value_or(""); + std::string trust_token = loginHelpers::jsonGet(jsonBuffer, "trust_token").value_or(""); + bool trust_this_computer = loginHelpers::jsonGet(jsonBuffer, "trust_this_computer").value_or(false); + std::array version = loginHelpers::jsonGet(jsonBuffer, "version").value_or(std::array{ 0, 0, 0 }); // Check major.minor but ignore trivial if (version[0] != SupportedXiloaderVersion[0] || version[1] != SupportedXiloaderVersion[1]) @@ -188,102 +190,131 @@ void auth_session::read_func() DebugSockets(fmt::format("LOGIN_ATTEMPT from {}", ipAddress)); // Look up and validate account password - if (!validatePassword(username, password)) + auto accountInfo = validatePassword(username, password); + if (!accountInfo) { - sendLoginResult(login_result::LOGIN_ERROR, 1); + sendLoginResult(login_result::LOGIN_ERROR); return; } - bool usedOTP = false; + auto [accountID, status] = *accountInfo; - if (otpHelpers::doesAccountNeedOTP(username, "TOTP")) + // Reject banned/non-normal accounts before processing OTP or trust tokens + if (!(status & ACCOUNT_STATUS_CODE::NORMAL)) { - if (!otpHelpers::validateTOTP(otp, otpHelpers::getAccountSecret(username, "TOTP"))) + // Purge any lingering trust tokens for banned accounts + if (status & ACCOUNT_STATUS_CODE::BANNED) { - sendLoginResult(login_result::LOGIN_ERROR, 1); - return; + otpHelpers::removeAllTrustTokens(accountID); } - - usedOTP = true; + sendLoginResult(login_result::LOGIN_FAIL); + return; } - // We've validated the password by this point, get account info - const auto rset = db::preparedStmt("SELECT accounts.id, accounts.status FROM accounts WHERE accounts.login = ?", username); - if (rset && rset->rowsCount() != 0 && rset->next()) + bool otpVerified = false; + + if (otpHelpers::doesAccountNeedOTP(accountID, "TOTP")) { - uint32 accountID = rset->get("id"); - uint32 status = rset->get("status"); + bool trustedByToken = false; - if (status & ACCOUNT_STATUS_CODE::NORMAL) + // Try trust token first + if (!trust_token.empty()) { - db::preparedStmt("UPDATE accounts SET accounts.timelastmodify = NULL WHERE accounts.id = ?", accountID); - - const auto payload = ipc::toBytesWithHeader(ipc::AccountLogin{ - .accountId = accountID, - }); - - zmqDealerWrapper_.outgoingQueue_.enqueue(zmq::message_t(payload.data(), payload.size())); + trustedByToken = otpHelpers::validateTrustToken(accountID, trust_token); + } - // set Satchel to the same size as inventory on all chars on their account if character has OTP - // Note: Upgrades happen in-game with gobbiebag - if (usedOTP) + // Fall back to OTP code if trust token invalid/missing + if (!trustedByToken) + { + if (otp.empty() && !trust_token.empty()) { - db::preparedStmt("UPDATE char_storage a JOIN char_storage b ON a.charid = b.charid " - "SET a.satchel = b.inventory " - "WHERE a.charid IN (SELECT charid FROM chars WHERE accid = ?)", - accountID); + // Trust token was provided but invalid, and no OTP to fall back to + sendLoginResult(login_result::LOGIN_ERROR_TRUST_TOKEN_INVALID); + return; } - // TODO: Lock out same account logging in multiple times. Can check data/view session existence on same IP/account? - // Not a real problem because the account is locked out when a character is logged in. - - /* - const auto rset = db::preparedStmt("SELECT charid " - "FROM accounts_sessions " - "WHERE accid = ? LIMIT 1", accountID); - if (rset && rset->rowsCount() != 0 && rset->next()) + + if (!otpHelpers::validateTOTP(otp, otpHelpers::getAccountSecret(username, "TOTP"))) { - // TODO: kick player out of map server if already logged in - // uint32 charid = rset->get("charid"); - - // This error message doesn't work when sent this way. Unknown how to transmit "1039" error message to a client already logged in. - // session_t& authenticatedSession = get_authenticated_session(socket_, session.sentAccountID); - // if (auto data = authenticatedSession.buffer_.data()session) - // { - // generateErrorMessage(data->buffer_.data(), 139); - // data->do_write(0x24); - // return; - //} - ref(buffer_.data(), 0) = LOGIN_ERROR_ALREADY_LOGGED_IN; - do_write(1); + sendLoginResult(login_result::LOGIN_ERROR); return; } - */ + } + + otpVerified = true; + } + + db::preparedStmt("UPDATE accounts SET accounts.timelastmodify = NULL WHERE accounts.id = ?", accountID); + + const auto payload = ipc::toBytesWithHeader(ipc::AccountLogin{ + .accountId = accountID, + }); + + zmqDealerWrapper_.outgoingQueue_.enqueue(zmq::message_t(payload.data(), payload.size())); + + // set Satchel to the same size as inventory on all chars on their account if character has OTP + // Note: Upgrades happen in-game with gobbiebag + if (otpVerified) + { + db::preparedStmt("UPDATE char_storage a JOIN char_storage b ON a.charid = b.charid " + "SET a.satchel = b.inventory " + "WHERE a.charid IN (SELECT charid FROM chars WHERE accid = ?)", + accountID); + } + // TODO: Lock out same account logging in multiple times. Can check data/view session existence on same IP/account? + // Not a real problem because the account is locked out when a character is logged in. - // Success - unsigned char hash[16]; - uint32 hashData = earth_time::timestamp() ^ getpid(); - md5(reinterpret_cast(&hashData), hash, sizeof(hashData)); + /* + const auto rset = db::preparedStmt("SELECT charid " + "FROM accounts_sessions " + "WHERE accid = ? LIMIT 1", accountID); + if (rset && rset->rowsCount() != 0 && rset->next()) + { + // TODO: kick player out of map server if already logged in + // uint32 charid = rset->get("charid"); + + // This error message doesn't work when sent this way. Unknown how to transmit "1039" error message to a client already logged in. + // session_t& authenticatedSession = get_authenticated_session(socket_, session.sentAccountID); + // if (auto data = authenticatedSession.buffer_.data()session) + // { + // generateErrorMessage(data->buffer_.data(), 139); + // data->do_write(0x24); + // return; + //} + ref(buffer_.data(), 0) = LOGIN_ERROR_ALREADY_LOGGED_IN; + do_write(1); + return; + } + */ - json loginSuccessReply; - loginSuccessReply["result"] = static_cast(login_result::LOGIN_SUCCESS); - loginSuccessReply["account_id"] = accountID; - loginSuccessReply["session_hash"] = hash; // This has to be sent as an array, json.dump() tries to convert to UTF which fails + // Success + unsigned char hash[16]; + uint32 hashData = earth_time::timestamp() ^ getpid(); + md5(reinterpret_cast(&hashData), hash, sizeof(hashData)); - sendJsonAsBuffer(loginSuccessReply); + json loginSuccessReply; + loginSuccessReply["result"] = static_cast(login_result::LOGIN_SUCCESS); + loginSuccessReply["account_id"] = accountID; + loginSuccessReply["session_hash"] = hash; // This has to be sent as an array, json.dump() tries to convert to UTF which fails - auto& session = loginHelpers::get_authenticated_session(ipAddress, asStringFromUntrustedSource(hash, sizeof(hash))); - session.accountID = accountID; - session.authorizedTime = timer::now(); + if (trust_this_computer && otpVerified) + { + try + { + auto newToken = otpHelpers::generateTrustToken(); + otpHelpers::saveTrustToken(accountID, newToken); + loginSuccessReply["trust_token"] = newToken; } - else if (status & ACCOUNT_STATUS_CODE::BANNED) + catch (const std::runtime_error& e) { - sendLoginResult(login_result::LOGIN_FAIL, 33); + ShowError(fmt::format("Failed to generate trust token: {}", e.what())); } } - else // No account match - { - sendLoginResult(login_result::LOGIN_FAIL, 1); - } + + sendJsonAsBuffer(loginSuccessReply); + + auto& session = loginHelpers::get_authenticated_session(ipAddress, asStringFromUntrustedSource(hash, sizeof(hash))); + session.accountID = accountID; + session.authorizedTime = timer::now(); } break; case login_cmd::LOGIN_CREATE: @@ -295,7 +326,7 @@ void auth_session::read_func() { ShowWarningFmt("login_parse: New account attempt <{}> but is disabled in settings.", username); - sendLoginResult(login_result::LOGIN_ERROR_CREATE_DISABLED, 1); + sendLoginResult(login_result::LOGIN_ERROR_CREATE_DISABLED); return; } @@ -303,7 +334,7 @@ void auth_session::read_func() const auto rset = db::preparedStmt("SELECT accounts.id FROM accounts WHERE accounts.login = ?", username); if (!rset) { - sendLoginResult(login_result::LOGIN_ERROR_CREATE, 1); + sendLoginResult(login_result::LOGIN_ERROR_CREATE); return; } @@ -319,7 +350,7 @@ void auth_session::read_func() } else { - sendLoginResult(login_result::LOGIN_ERROR_CREATE, 1); + sendLoginResult(login_result::LOGIN_ERROR_CREATE); return; } @@ -343,16 +374,16 @@ void auth_session::read_func() if (!rset2) { - sendLoginResult(login_result::LOGIN_ERROR_CREATE, 1); + sendLoginResult(login_result::LOGIN_ERROR_CREATE); return; } - sendLoginResult(login_result::LOGIN_SUCCESS_CREATE, 1); + sendLoginResult(login_result::LOGIN_SUCCESS_CREATE); return; } else { - sendLoginResult(login_result::LOGIN_ERROR_CREATE_TAKEN, 1); + sendLoginResult(login_result::LOGIN_ERROR_CREATE_TAKEN); return; } break; @@ -360,44 +391,29 @@ void auth_session::read_func() case login_cmd::LOGIN_CHANGE_PASSWORD: { // Look up and validate account password - if (!validatePassword(username, password)) + auto accountInfo = validatePassword(username, password); + if (!accountInfo) { - sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD, 1); + sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD); return; } + auto [accid, status] = *accountInfo; + if (otpHelpers::doesAccountNeedOTP(username, "TOTP")) { if (!otpHelpers::validateTOTP(otp, otpHelpers::getAccountSecret(username, "TOTP"))) { - sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD, 1); + sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD); return; } } - // Is this check redundant? - const auto rset = db::preparedStmt("SELECT accounts.id, accounts.status " - "FROM accounts " - "WHERE accounts.login = ?", - username); - if (rset == nullptr || rset->rowsCount() == 0) - { - ShowWarningFmt("login_parse: user <{}> could not be found using the provided information. Aborting.", username); - - sendLoginResult(login_result::LOGIN_ERROR, 1); - return; - } - - rset->next(); - - uint32 accid = rset->get("id"); - uint8 status = rset->get("status"); - if (status & ACCOUNT_STATUS_CODE::BANNED) { ShowInfoFmt("login_parse: banned user <{}> detected. Aborting.", username); - sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD, 1); + sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD); } if (status & ACCOUNT_STATUS_CODE::NORMAL) @@ -406,7 +422,7 @@ void auth_session::read_func() if (updated_password == "") { ShowWarningFmt("login_parse: Empty password: Could not update password for user <{}>.", username); - sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD, 1); + sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD); return; } @@ -420,10 +436,12 @@ void auth_session::read_func() if (!rset2) { ShowWarningFmt("login_parse: Error trying to update password in database for user <{}>.", username); - sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD, 1); + sendLoginResult(login_result::LOGIN_ERROR_CHANGE_PASSWORD); return; } + otpHelpers::removeAllTrustTokens(accid); + json loginErrorChangePasswordReply; loginErrorChangePasswordReply["result"] = login_result::LOGIN_SUCCESS_CHANGE_PASSWORD; loginErrorChangePasswordReply["account_id"] = 0; @@ -481,7 +499,10 @@ void auth_session::read_func() if (otpHelpers::validateTOTP(otp, secret) || strcmpi(otp.c_str(), recoveryCode.c_str()) == 0) { // validated - const auto rset = db::preparedStmt("DELETE FROM accounts_totp WHERE accounts_totp.accid = ? LIMIT 1", loginHelpers::getAccountId(username)); + uint32 accid = loginHelpers::getAccountId(username); + const auto rset = db::preparedStmt("DELETE FROM accounts_totp WHERE accounts_totp.accid = ? LIMIT 1", accid); + + otpHelpers::removeAllTrustTokens(accid); json sendSuccess; sendSuccess["result"] = login_result::LOGIN_SUCCESS_REMOVE_TOTP; @@ -578,13 +599,18 @@ void auth_session::do_write(std::size_t length) }); } -bool auth_session::validatePassword(std::string username, std::string password) +std::optional> auth_session::validatePassword(std::string username, std::string password) { + uint32 accountID = 0; + uint32 status = 0; + auto passHash = [&]() -> std::string { - const auto rset = db::preparedStmt("SELECT accounts.password FROM accounts WHERE accounts.login = ?", username); + const auto rset = db::preparedStmt("SELECT accounts.id, accounts.status, accounts.password FROM accounts WHERE accounts.login = ?", username); if (rset && rset->rowsCount() != 0 && rset->next()) { + accountID = rset->get("id"); + status = rset->get("status"); return rset->get("password"); } return ""; @@ -595,7 +621,7 @@ bool auth_session::validatePassword(std::string username, std::string password) // It's a BCrypt hash, so we can validate it. if (!BCrypt::validatePassword(password, passHash)) { - return false; + return std::nullopt; } } else @@ -607,16 +633,16 @@ bool auth_session::validatePassword(std::string username, std::string password) { if (rset->get(0) != passHash) { - return false; + return std::nullopt; } passHash = BCrypt::generateHash(password); db::preparedStmt("UPDATE accounts SET accounts.password = ? WHERE accounts.login = ?", passHash, username); if (!BCrypt::validatePassword(password, passHash)) { - return false; + return std::nullopt; } } } - return true; + return std::make_pair(accountID, status); } diff --git a/src/login/auth_session.h b/src/login/auth_session.h index 3682a51b623..4c53d572470 100644 --- a/src/login/auth_session.h +++ b/src/login/auth_session.h @@ -62,6 +62,7 @@ enum class login_result : uint8_t LOGIN_SUCCESS_CREATE_TOTP = 0x10, LOGIN_SUCCESS_VERIFY_TOTP = 0x11, LOGIN_SUCCESS_REMOVE_TOTP = 0x12, + LOGIN_ERROR_TRUST_TOKEN_INVALID = 0x13, }; constexpr std::array SupportedXiloaderVersion = { 2, 0, 0 }; @@ -136,5 +137,5 @@ class auth_session : public handler_session private: ZMQDealerWrapper& zmqDealerWrapper_; - bool validatePassword(std::string username, std::string password); + std::optional> validatePassword(std::string username, std::string password); }; diff --git a/src/login/otp_helpers.h b/src/login/otp_helpers.h index 0240aa51cd8..3354faa2546 100644 --- a/src/login/otp_helpers.h +++ b/src/login/otp_helpers.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -158,11 +159,10 @@ inline std::string getNewBase32Secret() return newSecret; } -inline bool doesAccountNeedOTP(const std::string& account, const std::string& secretType) +inline bool doesAccountNeedOTP(uint32 accid, const std::string& secretType) { if (secretType == "TOTP") { - const auto accid = loginHelpers::getAccountId(account); if (accid != 0) { const auto rset = db::preparedStmt("SELECT validated FROM accounts_totp where accid = ?", accid); @@ -184,6 +184,11 @@ inline bool doesAccountNeedOTP(const std::string& account, const std::string& se return false; } +inline bool doesAccountNeedOTP(const std::string& account, const std::string& secretType) +{ + return doesAccountNeedOTP(loginHelpers::getAccountId(account), secretType); +} + inline std::string createAccountSecret(const std::string& account, const std::string& secretType) { if (secretType == "TOTP") @@ -270,4 +275,83 @@ inline std::string getAccountRecoveryCode(const std::string& account, const std: return ""; } +inline std::string toHexString(const unsigned char* data, size_t len) +{ + static const char hexChars[] = "0123456789abcdef"; + std::string result; + result.reserve(len * 2); + for (size_t i = 0; i < len; ++i) + { + result += hexChars[(data[i] >> 4) & 0x0F]; + result += hexChars[data[i] & 0x0F]; + } + return result; +} + +inline bool isValidTrustTokenFormat(const std::string& token) +{ + if (token.size() != 64) + { + return false; + } + for (char c : token) + { + if (!std::isxdigit(static_cast(c))) + { + return false; + } + } + return true; +} + +inline std::string hashTrustToken(const std::string& token) +{ + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(token.data()), token.size(), hash); + return toHexString(hash, SHA256_DIGEST_LENGTH); +} + +inline std::string generateTrustToken() +{ + unsigned char bytes[32]; + if (RAND_bytes(bytes, sizeof(bytes)) != 1) + { + throw std::runtime_error("Failed to generate random bytes for trust token"); + } + return toHexString(bytes, sizeof(bytes)); +} + +inline void saveTrustToken(uint32 accid, const std::string& token) +{ + // Delete expired tokens for this account first + db::preparedStmt("DELETE FROM accounts_trust_tokens WHERE accid = ? AND expires <= NOW()", accid); + + // Insert new token with 30 day expiry (store hashed) + db::preparedStmt("INSERT INTO accounts_trust_tokens (token, accid, expires) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))", hashTrustToken(token), accid); +} + +inline bool validateTrustToken(uint32 accid, const std::string& token) +{ + if (!isValidTrustTokenFormat(token)) + { + return false; + } + + // Delete expired tokens for this account + db::preparedStmt("DELETE FROM accounts_trust_tokens WHERE accid = ? AND expires <= NOW()", accid); + + // Check for valid token (compare hashed) + const auto rset = db::preparedStmt("SELECT token FROM accounts_trust_tokens WHERE token = ? AND accid = ? AND expires > NOW()", hashTrustToken(token), accid); + if (rset && rset->rowsCount() != 0 && rset->next()) + { + return true; + } + return false; +} + +inline void removeAllTrustTokens(uint32 accid) +{ + db::preparedStmt("DELETE FROM accounts_trust_tokens WHERE accid = ?", accid); +} + } // namespace otpHelpers