/* * This file is part of the TrinityCore Project. See AUTHORS file for Copyright information * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the * Free Software Foundation; either version 2 of the License, or (at your * option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along * with this program. If not, see . */ #include "LoginRESTService.h" #include "Base64.h" #include "Common.h" #include "Configuration/Config.h" #include "CryptoHash.h" #include "CryptoRandom.h" #include "DatabaseEnv.h" #include "IpNetwork.h" #include "IteratorPair.h" #include "ProtobufJSON.h" #include "Resolver.h" #include "SslContext.h" #include "Timer.h" #include "Util.h" namespace Battlenet { LoginRESTService& LoginRESTService::Instance() { static LoginRESTService instance; return instance; } bool LoginRESTService::StartNetwork(Trinity::Asio::IoContext& ioContext, std::string const& bindIp, uint16 port, int32 threadCount) { if (!HttpService::StartNetwork(ioContext, bindIp, port, threadCount)) return false; using Trinity::Net::Http::RequestHandlerFlag; RegisterHandler(boost::beast::http::verb::get, "/bnetserver/login/"sv, [this](std::shared_ptr session, HttpRequestContext& context) { return HandleGetForm(std::move(session), context); }); RegisterHandler(boost::beast::http::verb::get, "/bnetserver/gameAccounts/"sv, [](std::shared_ptr session, HttpRequestContext& context) { return HandleGetGameAccounts(std::move(session), context); }); RegisterHandler(boost::beast::http::verb::get, "/bnetserver/portal/"sv, [this](std::shared_ptr session, HttpRequestContext& context) { return HandleGetPortal(std::move(session), context); }); RegisterHandler(boost::beast::http::verb::post, "/bnetserver/login/"sv, [this](std::shared_ptr session, HttpRequestContext& context) { return HandlePostLogin(std::move(session), context); }, RequestHandlerFlag::DoNotLogRequestContent); RegisterHandler(boost::beast::http::verb::post, "/bnetserver/login/srp/"sv, [](std::shared_ptr session, HttpRequestContext& context) { return HandlePostLoginSrpChallenge(std::move(session), context); }); RegisterHandler(boost::beast::http::verb::post, "/bnetserver/refreshLoginTicket/"sv, [this](std::shared_ptr session, HttpRequestContext& context) { return HandlePostRefreshLoginTicket(std::move(session), context); }); _bindIP = bindIp; _port = port; Trinity::Net::Resolver resolver(ioContext); _externalHostname = sConfigMgr->GetStringDefault("LoginREST.ExternalAddress"sv, "127.0.0.1"); std::ranges::transform(resolver.ResolveAll(_externalHostname, ""), std::back_inserter(_addresses), [](boost::asio::ip::tcp::endpoint const& endpoint) { return endpoint.address(); }); if (_addresses.empty()) { TC_LOG_ERROR("server.http.login", "Could not resolve LoginREST.ExternalAddress {}", _externalHostname); return false; } _localHostname = sConfigMgr->GetStringDefault("LoginREST.LocalAddress"sv, "127.0.0.1"); _firstLocalAddressIndex = _addresses.size(); std::ranges::transform(resolver.ResolveAll(_localHostname, ""), std::back_inserter(_addresses), [](boost::asio::ip::tcp::endpoint const& endpoint) { return endpoint.address(); }); if (_addresses.size() == _firstLocalAddressIndex) { TC_LOG_ERROR("server.http.login", "Could not resolve LoginREST.LocalAddress {}", _localHostname); return false; } // set up form inputs JSON::Login::FormInput* input; _formInputs.set_type(JSON::Login::LOGIN_FORM); input = _formInputs.add_inputs(); input->set_input_id("account_name"); input->set_type("text"); input->set_label("E-mail"); input->set_max_length(320); input = _formInputs.add_inputs(); input->set_input_id("password"); input->set_type("password"); input->set_label("Password"); input->set_max_length(128); input = _formInputs.add_inputs(); input->set_input_id("log_in_submit"); input->set_type("submit"); input->set_label("Log In"); _loginTicketDuration = sConfigMgr->GetIntDefault("LoginREST.TicketDuration"sv, 3600); MigrateLegacyPasswordHashes(); _acceptor->AsyncAccept([this](Trinity::Net::IoContextTcpSocket&& sock, uint32 threadIndex) { OnSocketOpen(std::move(sock), threadIndex); }); return true; } std::string const& LoginRESTService::GetHostnameForClient(boost::asio::ip::address const& address) const { if (Optional addressIndex = Trinity::Net::SelectAddressForClient(address, _addresses)) return *addressIndex >= _firstLocalAddressIndex ? _localHostname : _externalHostname; if (address.is_loopback()) return _localHostname; return _externalHostname; } std::string LoginRESTService::ExtractAuthorization(HttpRequest const& request) { std::string ticket; auto itr = request.find(boost::beast::http::field::authorization); if (itr == request.end()) return ticket; std::string_view authorization = Trinity::Net::Http::ToStdStringView(itr->value()); constexpr std::string_view BASIC_PREFIX = "Basic "sv; if (authorization.starts_with(BASIC_PREFIX)) authorization.remove_prefix(BASIC_PREFIX.length()); Optional> decoded = Trinity::Encoding::Base64::Decode(authorization); if (!decoded) return ticket; std::string_view decodedHeader(reinterpret_cast(decoded->data()), decoded->size()); if (std::size_t ticketEnd = decodedHeader.find(':'); ticketEnd != std::string_view::npos) decodedHeader.remove_suffix(decodedHeader.length() - ticketEnd); ticket = decodedHeader; return ticket; } LoginRESTService::RequestHandlerResult LoginRESTService::HandleGetForm(std::shared_ptr session, HttpRequestContext& context) const { JSON::Login::FormInputs form = _formInputs; form.set_srp_url(Trinity::StringFormat("http{}://{}:{}/bnetserver/login/srp/", !SslContext::UsesDevWildcardCertificate() ? "s" : "", GetHostnameForClient(session->GetRemoteIpAddress()), _port)); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(form); return RequestHandlerResult::Handled; } LoginRESTService::RequestHandlerResult LoginRESTService::HandleGetGameAccounts(std::shared_ptr session, HttpRequestContext& context) { std::string ticket = ExtractAuthorization(context.request); if (ticket.empty()) return HandleUnauthorized(std::move(session), context); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_BNET_GAME_ACCOUNT_LIST); stmt->setString(0, ticket); session->QueueQuery(LoginDatabase.AsyncQuery(stmt) .WithPreparedCallback([session, context = std::move(context)](PreparedQueryResult result) mutable { JSON::Login::GameAccountList gameAccounts; if (result) { auto formatDisplayName = [](char const* name) -> std::string { if (char const* hashPos = strchr(name, '#')) return std::string("WoW") + ++hashPos; else return name; }; time_t now = time(nullptr); do { Field* fields = result->Fetch(); JSON::Login::GameAccountInfo* gameAccount = gameAccounts.add_game_accounts(); gameAccount->set_display_name(formatDisplayName(fields[0].GetCString())); gameAccount->set_expansion(fields[1].GetUInt8()); if (!fields[2].IsNull()) { uint32 banDate = fields[2].GetUInt32(); uint32 unbanDate = fields[3].GetUInt32(); gameAccount->set_is_suspended(unbanDate > now); gameAccount->set_is_banned(banDate == unbanDate); gameAccount->set_suspension_reason(fields[4].GetString()); gameAccount->set_suspension_expires(unbanDate); } } while (result->NextRow()); } context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(gameAccounts); session->SendResponse(context); })); return RequestHandlerResult::Async; } LoginRESTService::RequestHandlerResult LoginRESTService::HandleGetPortal(std::shared_ptr session, HttpRequestContext& context) const { context.response.set(boost::beast::http::field::content_type, "text/plain"); context.response.body() = Trinity::StringFormat("{}:{}", GetHostnameForClient(session->GetRemoteIpAddress()), sConfigMgr->GetIntDefault("BattlenetPort", 1119)); return RequestHandlerResult::Handled; } LoginRESTService::RequestHandlerResult LoginRESTService::HandlePostLogin(std::shared_ptr session, HttpRequestContext& context) const { std::shared_ptr loginForm = std::make_shared(); if (!::JSON::Deserialize(context.request.body(), loginForm.get())) { JSON::Login::LoginResult loginResult; loginResult.set_authentication_state(JSON::Login::LOGIN); loginResult.set_error_code("UNABLE_TO_DECODE"); loginResult.set_error_message("There was an internal error while connecting to Battle.net. Please try again later."); context.response.result(boost::beast::http::status::bad_request); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginResult); session->SendResponse(context); return RequestHandlerResult::Handled; } auto getInputValue = [](JSON::Login::LoginForm const* loginForm, std::string_view inputId) -> std::string { for (int32 i = 0; i < loginForm->inputs_size(); ++i) if (loginForm->inputs(i).input_id() == inputId) return loginForm->inputs(i).value(); return ""; }; std::string login(getInputValue(loginForm.get(), "account_name")); Utf8ToUpperOnlyLatin(login); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_BNET_AUTHENTICATION); stmt->setString(0, login); session->QueueQuery(LoginDatabase.AsyncQuery(stmt) .WithChainingPreparedCallback([this, session, context = std::move(context), loginForm = std::move(loginForm), getInputValue](QueryCallback& callback, PreparedQueryResult result) mutable { if (!result) { JSON::Login::LoginResult loginResult; loginResult.set_authentication_state(JSON::Login::DONE); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginResult); session->SendResponse(context); return; } std::string login(getInputValue(loginForm.get(), "account_name")); Utf8ToUpperOnlyLatin(login); bool passwordCorrect = false; Optional serverM2; Field* fields = result->Fetch(); uint32 accountId = fields[0].GetUInt32(); if (!session->GetSessionState()->Srp) { SrpVersion version = SrpVersion(fields[1].GetInt8()); std::string srpUsername = ByteArrayToHexStr(Trinity::Crypto::SHA256::GetDigestOf(login)); Trinity::Crypto::SRP::Salt s = fields[2].GetBinary(); Trinity::Crypto::SRP::Verifier v = fields[3].GetBinary(); session->GetSessionState()->Srp = CreateSrpImplementation(version, SrpHashFunction::Sha256, srpUsername, s, v); std::string password(getInputValue(loginForm.get(), "password")); if (version == SrpVersion::v1) Utf8ToUpperOnlyLatin(password); passwordCorrect = session->GetSessionState()->Srp->CheckCredentials(srpUsername, password); } else { BigNumber A(getInputValue(loginForm.get(), "public_A")); BigNumber M1(getInputValue(loginForm.get(), "client_evidence_M1")); if (Optional sessionKey = session->GetSessionState()->Srp->VerifyClientEvidence(A, M1)) { passwordCorrect = true; serverM2 = session->GetSessionState()->Srp->CalculateServerEvidence(A, M1, *sessionKey).AsHexStr(); } } uint32 failedLogins = fields[4].GetUInt32(); std::string loginTicket = fields[5].GetString(); uint32 loginTicketExpiry = fields[6].GetUInt32(); bool isBanned = fields[7].GetUInt64() != 0; if (!passwordCorrect) { if (!isBanned) { std::string ip_address = session->GetRemoteIpAddress().to_string(); uint32 maxWrongPassword = uint32(sConfigMgr->GetIntDefault("WrongPass.MaxCount", 0)); if (sConfigMgr->GetBoolDefault("WrongPass.Logging", false)) TC_LOG_DEBUG("server.http.login", "[{}, Account {}, Id {}] Attempted to connect with wrong password!", ip_address, login, accountId); if (maxWrongPassword) { LoginDatabaseTransaction trans = LoginDatabase.BeginTransaction(); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_BNET_FAILED_LOGINS); stmt->setUInt32(0, accountId); trans->Append(stmt); ++failedLogins; TC_LOG_DEBUG("server.http.login", "MaxWrongPass : {}, failed_login : {}", maxWrongPassword, accountId); if (failedLogins >= maxWrongPassword) { BanMode banType = BanMode(sConfigMgr->GetIntDefault("WrongPass.BanType", uint16(BanMode::BAN_IP))); int32 banTime = sConfigMgr->GetIntDefault("WrongPass.BanTime", 600); if (banType == BanMode::BAN_ACCOUNT) { stmt = LoginDatabase.GetPreparedStatement(LOGIN_INS_BNET_ACCOUNT_AUTO_BANNED); stmt->setUInt32(0, accountId); } else { stmt = LoginDatabase.GetPreparedStatement(LOGIN_INS_IP_AUTO_BANNED); stmt->setString(0, ip_address); } stmt->setUInt32(1, banTime); trans->Append(stmt); stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_BNET_RESET_FAILED_LOGINS); stmt->setUInt32(0, accountId); trans->Append(stmt); } LoginDatabase.CommitTransaction(trans); } } JSON::Login::LoginResult loginResult; loginResult.set_authentication_state(JSON::Login::DONE); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginResult); session->SendResponse(context); return; } if (loginTicket.empty() || loginTicketExpiry < time(nullptr)) loginTicket = "TC-" + ByteArrayToHexStr(Trinity::Crypto::GetRandomBytes<20>()); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_BNET_AUTHENTICATION); stmt->setString(0, loginTicket); stmt->setUInt32(1, time(nullptr) + _loginTicketDuration); stmt->setUInt32(2, accountId); callback.WithPreparedCallback([session, context = std::move(context), loginTicket = std::move(loginTicket), serverM2 = std::move(serverM2)](PreparedQueryResult) mutable { JSON::Login::LoginResult loginResult; loginResult.set_authentication_state(JSON::Login::DONE); loginResult.set_login_ticket(loginTicket); if (serverM2) loginResult.set_server_evidence_m2(*serverM2); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginResult); session->SendResponse(context); }).SetNextQuery(LoginDatabase.AsyncQuery(stmt)); })); return RequestHandlerResult::Async; } LoginRESTService::RequestHandlerResult LoginRESTService::HandlePostLoginSrpChallenge(std::shared_ptr session, HttpRequestContext& context) { JSON::Login::LoginForm loginForm; if (!::JSON::Deserialize(context.request.body(), &loginForm)) { JSON::Login::LoginResult loginResult; loginResult.set_authentication_state(JSON::Login::LOGIN); loginResult.set_error_code("UNABLE_TO_DECODE"); loginResult.set_error_message("There was an internal error while connecting to Battle.net. Please try again later."); context.response.result(boost::beast::http::status::bad_request); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginResult); session->SendResponse(context); return RequestHandlerResult::Handled; } std::string login; for (int32 i = 0; i < loginForm.inputs_size(); ++i) if (loginForm.inputs(i).input_id() == "account_name") login = loginForm.inputs(i).value(); Utf8ToUpperOnlyLatin(login); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_BNET_CHECK_PASSWORD_BY_EMAIL); stmt->setString(0, login); session->QueueQuery(LoginDatabase.AsyncQuery(stmt) .WithPreparedCallback([session, context = std::move(context), login = std::move(login)](PreparedQueryResult result) mutable { if (!result) { JSON::Login::LoginResult loginResult; loginResult.set_authentication_state(JSON::Login::DONE); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginResult); session->SendResponse(context); return; } Field* fields = result->Fetch(); SrpVersion version = SrpVersion(fields[0].GetInt8()); SrpHashFunction hashFunction = SrpHashFunction::Sha256; std::string srpUsername = ByteArrayToHexStr(Trinity::Crypto::SHA256::GetDigestOf(login)); Trinity::Crypto::SRP::Salt s = fields[1].GetBinary(); Trinity::Crypto::SRP::Verifier v = fields[2].GetBinary(); session->GetSessionState()->Srp = CreateSrpImplementation(version, hashFunction, srpUsername, s, v); if (!session->GetSessionState()->Srp) { context.response.result(boost::beast::http::status::internal_server_error); session->SendResponse(context); return; } JSON::Login::SrpLoginChallenge challenge; challenge.set_version(session->GetSessionState()->Srp->GetVersion()); challenge.set_iterations(session->GetSessionState()->Srp->GetXIterations()); challenge.set_modulus(session->GetSessionState()->Srp->GetN().AsHexStr()); challenge.set_generator(session->GetSessionState()->Srp->Getg().AsHexStr()); challenge.set_hash_function([=] { switch (hashFunction) { case SrpHashFunction::Sha256: return "SHA-256"; case SrpHashFunction::Sha512: return "SHA-512"; default: break; } return ""; }()); challenge.set_username(srpUsername); challenge.set_salt(ByteArrayToHexStr(session->GetSessionState()->Srp->s)); challenge.set_public_b(session->GetSessionState()->Srp->B.AsHexStr()); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(challenge); session->SendResponse(context); })); return RequestHandlerResult::Async; } LoginRESTService::RequestHandlerResult LoginRESTService::HandlePostRefreshLoginTicket(std::shared_ptr session, HttpRequestContext& context) const { std::string ticket = ExtractAuthorization(context.request); if (ticket.empty()) return HandleUnauthorized(std::move(session), context); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_BNET_EXISTING_AUTHENTICATION); stmt->setString(0, ticket); session->QueueQuery(LoginDatabase.AsyncQuery(stmt) .WithPreparedCallback([this, session, context = std::move(context), ticket = std::move(ticket)](PreparedQueryResult result) mutable { JSON::Login::LoginRefreshResult loginRefreshResult; if (result) { uint32 loginTicketExpiry = (*result)[0].GetUInt32(); time_t now = time(nullptr); if (loginTicketExpiry > now) { loginRefreshResult.set_login_ticket_expiry(now + _loginTicketDuration); LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_BNET_EXISTING_AUTHENTICATION); stmt->setUInt32(0, uint32(now + _loginTicketDuration)); stmt->setString(1, ticket); LoginDatabase.Execute(stmt); } else loginRefreshResult.set_is_expired(true); } else loginRefreshResult.set_is_expired(true); context.response.set(boost::beast::http::field::content_type, "application/json;charset=utf-8"); context.response.body() = ::JSON::Serialize(loginRefreshResult); session->SendResponse(context); })); return RequestHandlerResult::Async; } std::unique_ptr LoginRESTService::CreateSrpImplementation(SrpVersion version, SrpHashFunction hashFunction, std::string const& username, Trinity::Crypto::SRP::Salt const& salt, Trinity::Crypto::SRP::Verifier const& verifier) { if (version == SrpVersion::v2) { if (hashFunction == SrpHashFunction::Sha256) return std::make_unique>(username, salt, verifier); if (hashFunction == SrpHashFunction::Sha512) return std::make_unique>(username, salt, verifier); } if (version == SrpVersion::v1) { if (hashFunction == SrpHashFunction::Sha256) return std::make_unique>(username, salt, verifier); if (hashFunction == SrpHashFunction::Sha512) return std::make_unique>(username, salt, verifier); } return nullptr; } std::shared_ptr LoginRESTService::CreateNewSessionState(boost::asio::ip::address const& address) { std::shared_ptr state = std::make_shared(); InitAndStoreSessionState(state, address); return state; } void LoginRESTService::MigrateLegacyPasswordHashes() const { if (!LoginDatabase.Query("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = SCHEMA() AND TABLE_NAME = 'battlenet_accounts' AND COLUMN_NAME = 'sha_pass_hash'")) return; TC_LOG_INFO(_logger, "Updating password hashes..."); uint32 const start = getMSTime(); // the auth update query nulls salt/verifier if they cannot be converted // if they are non-null but s/v have been cleared, that means a legacy tool touched our auth DB (otherwise, the core might've done it itself, it used to use those hacks too) QueryResult result = LoginDatabase.Query("SELECT id, sha_pass_hash, IF((salt IS null) OR (verifier IS null), 0, 1) AS shouldWarn FROM battlenet_accounts WHERE sha_pass_hash != DEFAULT(sha_pass_hash) OR salt IS NULL OR verifier IS NULL"); if (!result) { TC_LOG_INFO(_logger, ">> No password hashes to update - this took us {} ms to realize", GetMSTimeDiffToNow(start)); return; } bool hadWarning = false; uint32 c = 0; LoginDatabaseTransaction tx = LoginDatabase.BeginTransaction(); do { uint32 const id = (*result)[0].GetUInt32(); Trinity::Crypto::SRP::Salt salt = Trinity::Crypto::GetRandomBytes(); BigNumber x = Trinity::Crypto::SHA256::GetDigestOf(salt, HexStrToByteArray((*result)[1].GetString(), true)); Trinity::Crypto::SRP::Verifier verifier = Trinity::Crypto::SRP::BnetSRP6v1Base::g.ModExp(x, Trinity::Crypto::SRP::BnetSRP6v1Base::N).ToByteVector(); if ((*result)[2].GetInt64()) { if (!hadWarning) { hadWarning = true; TC_LOG_WARN(_logger, " ========\n" "(!) You appear to be using an outdated external account management tool.\n" "(!) Update your external tool.\n" "(!!) If no update is available, refer your tool's developer to https://github.com/TrinityCore/TrinityCore/issues/25157.\n" " ========"); } } LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_BNET_LOGON); stmt->setInt8(0, AsUnderlyingType(SrpVersion::v1)); stmt->setBinary(1, salt); stmt->setBinary(2, std::move(verifier)); stmt->setUInt32(3, id); tx->Append(stmt); tx->Append(Trinity::StringFormat("UPDATE battlenet_accounts SET sha_pass_hash = DEFAULT(sha_pass_hash) WHERE id = {}", id).c_str()); if (tx->GetSize() >= 10000) { LoginDatabase.CommitTransaction(tx); tx = LoginDatabase.BeginTransaction(); } ++c; } while (result->NextRow()); LoginDatabase.CommitTransaction(tx); TC_LOG_INFO(_logger, ">> {} password hashes updated in {} ms", c, GetMSTimeDiffToNow(start)); } }