diff --git a/sql/accounts_login_tokens.sql b/sql/accounts_login_tokens.sql new file mode 100644 index 00000000000..8f93881a354 --- /dev/null +++ b/sql/accounts_login_tokens.sql @@ -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 +); diff --git a/src/login/auth_session.cpp b/src/login/auth_session.cpp index 5144c7ccbd0..af92482327a 100644 --- a/src/login/auth_session.cpp +++ b/src/login/auth_session.cpp @@ -25,6 +25,8 @@ #include "common/utils.h" #include "otp_helpers.h" +#include + #include #include @@ -152,6 +154,7 @@ void auth_session::read_func() 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(""); + std::string login_token = loginHelpers::jsonGet(jsonBuffer, "login_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 }); @@ -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(code)) @@ -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 @@ -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; diff --git a/src/login/auth_session.h b/src/login/auth_session.h index fc87797e846..7699bd5321d 100644 --- a/src/login/auth_session.h +++ b/src/login/auth_session.h @@ -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 SupportedXiloaderVersion = { 2, 1, 0 }; diff --git a/src/login/otp_helpers.h b/src/login/otp_helpers.h index 3354faa2546..b0d350fe8d7 100644 --- a/src/login/otp_helpers.h +++ b/src/login/otp_helpers.h @@ -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> 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("accid"); + const uint32 status = sel->get("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