Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions sql/accounts_login_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Single-use, short-TTL game-launch tokens.
--
-- Minted by an external auth service for an already-authenticated session and
-- consumed once by xi_connect's LOGIN_ATTEMPT. Stands in for BOTH password and
-- OTP at boot (a trust token only bypasses OTP). Single-use + TTL are enforced
-- in code, not the schema; only the SHA-256 hash is stored.
CREATE TABLE IF NOT EXISTS `accounts_login_tokens` (
`token` CHAR(64) NOT NULL,
`accid` INT UNSIGNED NOT NULL,
`expires` DATETIME NOT NULL,
PRIMARY KEY (`token`),
INDEX `idx_accid` (`accid`),
INDEX `idx_expires` (`expires`),
CONSTRAINT `fk_login_accid` FOREIGN KEY (`accid`) REFERENCES `accounts`(`id`) ON DELETE CASCADE
);
65 changes: 47 additions & 18 deletions src/login/auth_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
#include "common/utils.h"
#include "otp_helpers.h"

#include <tuple>

#include <bcrypt/BCrypt.hpp>

#include <nlohmann/json.hpp>
Expand Down Expand Up @@ -152,6 +154,7 @@ void auth_session::read_func()
std::string updated_password = loginHelpers::jsonGet<std::string>(jsonBuffer, "new_password").value_or("");
std::string otp = loginHelpers::jsonGet<std::string>(jsonBuffer, "otp").value_or("");
std::string trust_token = loginHelpers::jsonGet<std::string>(jsonBuffer, "trust_token").value_or("");
std::string login_token = loginHelpers::jsonGet<std::string>(jsonBuffer, "login_token").value_or("");
bool trust_this_computer = loginHelpers::jsonGet<bool>(jsonBuffer, "trust_this_computer").value_or(false);
std::array<uint8_t, 3> version = loginHelpers::jsonGet<uint8, 3>(jsonBuffer, "version").value_or(std::array<uint8_t, 3>{ 0, 0, 0 });

Expand All @@ -165,17 +168,22 @@ void auth_session::read_func()

DebugSockets(fmt::format("auth code: {} from {}", code, ipAddress));

// data checks
if (loginHelpers::isStringMalformed(username, 16))
// data checks. A launch-token login identifies the account by the token alone
// and carries no username/password, so skip those checks when one is present
// (isStringMalformed rejects empty strings).
if (login_token.empty())
{
ShowWarningFmt("login_parse: malformed username from {}", ipAddress);
return;
}
if (loginHelpers::isStringMalformed(username, 16))
{
ShowWarningFmt("login_parse: malformed username from {}", ipAddress);
return;
}

if (loginHelpers::isStringMalformed(password, 32))
{
ShowWarningFmt("login_parse: malformed password from {}", ipAddress);
return;
if (loginHelpers::isStringMalformed(password, 32))
{
ShowWarningFmt("login_parse: malformed password from {}", ipAddress);
return;
}
}

switch (static_cast<login_cmd>(code))
Expand All @@ -189,17 +197,37 @@ void auth_session::read_func()
{
DebugSockets(fmt::format("LOGIN_ATTEMPT from {}", ipAddress));

// Look up and validate account password
auto accountInfo = validatePassword(username, password);
if (!accountInfo)
uint32 accountID = 0;
uint32 status = 0;

// Launch-token boot: a single-use token stands in for BOTH password
// and OTP, so skip validatePassword and the OTP block. The minting
// service already authenticated the session (incl. any 2FA).
const bool viaLaunchToken = !login_token.empty();
if (viaLaunchToken)
{
sendLoginResult(login_result::LOGIN_ERROR);
return;
auto launchInfo = otpHelpers::validateAndConsumeLaunchToken(login_token);
if (!launchInfo)
{
sendLoginResult(login_result::LOGIN_ERROR_LAUNCH_TOKEN_INVALID);
return;
}
std::tie(accountID, status) = *launchInfo;
}
else
{
// Look up and validate account password
auto accountInfo = validatePassword(username, password);
if (!accountInfo)
{
sendLoginResult(login_result::LOGIN_ERROR);
return;
}
std::tie(accountID, status) = *accountInfo;
}

auto [accountID, status] = *accountInfo;

// Reject banned/non-normal accounts before processing OTP or trust tokens
// Reject banned/non-normal accounts before processing OTP or trust tokens.
// Runs on BOTH the password and launch-token paths.
if (!(status & ACCOUNT_STATUS_CODE::NORMAL))
{
// Purge any lingering trust tokens for banned accounts
Expand All @@ -213,7 +241,8 @@ void auth_session::read_func()

bool otpVerified = false;

if (otpHelpers::doesAccountNeedOTP(accountID, "TOTP"))
// Launch tokens already satisfied 2FA at mint time — skip the OTP block.
if (!viaLaunchToken && otpHelpers::doesAccountNeedOTP(accountID, "TOTP"))
{
bool trustedByToken = false;

Expand Down
33 changes: 17 additions & 16 deletions src/login/auth_session.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,23 @@ enum class login_cmd : uint8_t
/*return result*/
enum class login_result : uint8_t
{
LOGIN_FAIL = 0x00,
LOGIN_SUCCESS = 0x01,
LOGIN_ERROR = 0x02,
LOGIN_SUCCESS_CREATE = 0x03,
LOGIN_ERROR_CREATE_TAKEN = 0x04,
LOGIN_REQUEST_NEW_PASSWORD = 0x05, // is this used?
LOGIN_SUCCESS_CHANGE_PASSWORD = 0x06,
LOGIN_ERROR_CHANGE_PASSWORD = 0x07,
LOGIN_ERROR_CREATE_DISABLED = 0x08,
LOGIN_ERROR_CREATE = 0x09,
LOGIN_ERROR_ALREADY_LOGGED_IN = 0x0A,
LOGIN_ERROR_VERSION_UNSUPPORTED = 0x0B,
LOGIN_SUCCESS_CREATE_TOTP = 0x10,
LOGIN_SUCCESS_VERIFY_TOTP = 0x11,
LOGIN_SUCCESS_REMOVE_TOTP = 0x12,
LOGIN_ERROR_TRUST_TOKEN_INVALID = 0x13,
LOGIN_FAIL = 0x00,
LOGIN_SUCCESS = 0x01,
LOGIN_ERROR = 0x02,
LOGIN_SUCCESS_CREATE = 0x03,
LOGIN_ERROR_CREATE_TAKEN = 0x04,
LOGIN_REQUEST_NEW_PASSWORD = 0x05, // is this used?
LOGIN_SUCCESS_CHANGE_PASSWORD = 0x06,
LOGIN_ERROR_CHANGE_PASSWORD = 0x07,
LOGIN_ERROR_CREATE_DISABLED = 0x08,
LOGIN_ERROR_CREATE = 0x09,
LOGIN_ERROR_ALREADY_LOGGED_IN = 0x0A,
LOGIN_ERROR_VERSION_UNSUPPORTED = 0x0B,
LOGIN_SUCCESS_CREATE_TOTP = 0x10,
LOGIN_SUCCESS_VERIFY_TOTP = 0x11,
LOGIN_SUCCESS_REMOVE_TOTP = 0x12,
LOGIN_ERROR_TRUST_TOKEN_INVALID = 0x13,
LOGIN_ERROR_LAUNCH_TOKEN_INVALID = 0x14,
};

constexpr std::array<uint8, 3> SupportedXiloaderVersion = { 2, 1, 0 };
Expand Down
39 changes: 39 additions & 0 deletions src/login/otp_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -354,4 +354,43 @@ inline void removeAllTrustTokens(uint32 accid)
db::preparedStmt("DELETE FROM accounts_trust_tokens WHERE accid = ?", accid);
}

// Validate and consume a single-use launch token (same 64-hex format and
// SHA-256-at-rest hashing as trust tokens). Returns (accid, account status) only
// if the token is valid and this caller's DELETE removed the row (single-use).
// Status is returned so the caller can run the normal ban/NORMAL check.
inline Maybe<std::pair<uint32, uint32>> validateAndConsumeLaunchToken(const std::string& token)
{
if (!isValidTrustTokenFormat(token)) // 64 hex chars — same format as trust tokens
{
return std::nullopt;
}

const auto hashed = hashTrustToken(token); // SHA-256 hex — same at-rest hashing

// Resolve accid + account status for a still-valid token.
const auto sel = db::preparedStmt(
"SELECT lt.accid AS accid, a.status AS status "
"FROM accounts_login_tokens lt "
"JOIN accounts a ON a.id = lt.accid "
"WHERE lt.token = ? AND lt.expires > NOW()",
hashed);
if (!sel || !sel->next())
{
return std::nullopt;
}

const uint32 accid = sel->get<uint32>("accid");
const uint32 status = sel->get<uint32>("status");

// Consume atomically — single-use. Only the caller whose DELETE actually
// removes the row (rowsAffected == 1) wins; a concurrent replay removes 0.
const auto del = db::preparedStmt("DELETE FROM accounts_login_tokens WHERE token = ? AND expires > NOW()", hashed);
if (!del || del->rowsAffected() != 1)
{
return std::nullopt;
}

return std::make_pair(accid, status);
}

} // namespace otpHelpers
Loading