diff --git a/SteamPrefill.Test/SteamPrefill.Test.csproj b/SteamPrefill.Test/SteamPrefill.Test.csproj index 5effe546..cfac96d8 100644 --- a/SteamPrefill.Test/SteamPrefill.Test.csproj +++ b/SteamPrefill.Test/SteamPrefill.Test.csproj @@ -24,6 +24,12 @@ + + + ..\lib\SteamKit2.dll + + + ..\lib\Spectre.Console.dll diff --git a/SteamPrefill/Handlers/Steam/Steam3Session.cs b/SteamPrefill/Handlers/Steam/Steam3Session.cs index 0767bb94..a81a720e 100644 --- a/SteamPrefill/Handlers/Steam/Steam3Session.cs +++ b/SteamPrefill/Handlers/Steam/Steam3Session.cs @@ -9,14 +9,12 @@ public sealed class Steam3Session : IDisposable public readonly SteamApps SteamAppsApi; public readonly Client CdnClient; public SteamUnifiedMessages.UnifiedService unifiedPlayerService; - private readonly CallbackManager _callbackManager; private SteamUser.LogOnDetails _logonDetails; private readonly IAnsiConsole _ansiConsole; private readonly UserAccountStore _userAccountStore; - public readonly LicenseManager LicenseManager; public SteamID _steamId; @@ -48,8 +46,6 @@ public Steam3Session(IAnsiConsole ansiConsole) }); _callbackManager.Subscribe(loggedOn => _loggedOnCallbackResult = loggedOn); - _callbackManager.Subscribe(UpdateMachineAuthCallback); - _callbackManager.Subscribe(LoginKeyCallback); _callbackManager.Subscribe(LicenseListCallback); CdnClient = new Client(_steamClient); @@ -70,12 +66,17 @@ public async Task LoginToSteamAsync() while (!logonSuccess) { _callbackManager.RunWaitAllCallbacks(timeout: TimeSpan.FromMilliseconds(50)); + SteamUser.LoggedOnCallback logonResult = null; - _ansiConsole.StatusSpinner().Start("Connecting to Steam...", ctx => + await _ansiConsole.StatusSpinner().StartAsync("Connecting to Steam...", async ctx => { ConnectToSteam(); - ctx.Status = "Logging into Steam..."; + // Making sure that we have a valid access token before moving onto the login + ctx.Status = "Retrieving access token..."; + await GetAccessTokenAsync(); + + ctx.Status = "Logging in to Steam..."; logonResult = AttemptSteamLogin(); }); @@ -87,11 +88,47 @@ public async Task LoginToSteamAsync() throw new SteamLoginException("Unable to login to Steam! Try again in a few moments..."); } } + } + + private async Task GetAccessTokenAsync() + { + if (_userAccountStore.AccessTokenIsValid()) + { + return; + } + + _ansiConsole.LogMarkupLine("Requesting new access token..."); - _ansiConsole.StatusSpinner().Start("Saving Steam session...", _ => + // Begin authenticating via credentials + var authSession = await _steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails { - TryWaitForLoginKey(); + Username = _logonDetails.Username, + Password = _logonDetails.Password, + IsPersistentSession = true, + Authenticator = new UserConsoleAuthenticator() }); + + // Starting polling Steam for authentication response + var pollResponse = await authSession.PollingWaitForResultAsync(); + _userAccountStore.AccessToken = pollResponse.RefreshToken; + _userAccountStore.Save(); + + // Clearing password so it doesn't stay in memory + _logonDetails.Password = null; + GC.Collect(); + } + + private async Task ConfigureLoginDetailsAsync() + { + var username = await _userAccountStore.GetUsernameAsync(_ansiConsole); + + _logonDetails = new SteamUser.LogOnDetails + { + Username = username, + ShouldRememberPassword = true, + Password = _userAccountStore.AccessTokenIsValid() ? null : await _ansiConsole.ReadPasswordAsync(), + LoginID = _userAccountStore.SessionId + }; } #region Connecting to Steam @@ -124,42 +161,24 @@ private void ConnectToSteam() } } } + _ansiConsole.LogMarkupLine("Connected to Steam!"); } #endregion #region Logging into Steam - private async Task ConfigureLoginDetailsAsync() - { - var username = await _userAccountStore.GetUsernameAsync(_ansiConsole); - - string sessionToken; - _userAccountStore.SessionTokens.TryGetValue(username, out sessionToken); - - _logonDetails = new SteamUser.LogOnDetails - { - Username = username, - Password = sessionToken == null ? await _ansiConsole.ReadPasswordAsync() : null, - ShouldRememberPassword = true, - LoginKey = sessionToken, - LoginID = _userAccountStore.SessionId - }; - // Sentry file is required when using Steam Guard w\ email - if (_userAccountStore.SentryData.TryGetValue(_logonDetails.Username, out var bytes)) - { - _logonDetails.SentryFileHash = bytes.ToSha1(); - } - } - private SteamUser.LoggedOnCallback _loggedOnCallbackResult; + private int _failedLogonAttempts; [SuppressMessage("Maintainability", "CA1508:Avoid dead conditional code", Justification = "while() loop is not infinite. _loggedOnCallbackResult is set after logging into Steam")] private SteamUser.LoggedOnCallback AttemptSteamLogin() { var timeoutAfter = DateTime.Now.AddSeconds(30); - + // Need to reset this global result value, as it will be populated once the logon callback completes _loggedOnCallbackResult = null; + + _logonDetails.AccessToken = _userAccountStore.AccessToken; _steamUser.LogOn(_logonDetails); // Busy waiting for the callback to complete, then we can return the callback value synchronously @@ -174,8 +193,6 @@ private SteamUser.LoggedOnCallback AttemptSteamLogin() return _loggedOnCallbackResult; } - private int _failedLogonAttempts; - [SuppressMessage("", "VSTHRD002:Synchronously waiting on tasks may cause deadlocks.", Justification = "Its not possible for this callback method to be async, must block synchronously")] private bool HandleLogonResult(SteamUser.LoggedOnCallback logonResult) { @@ -197,15 +214,6 @@ private bool HandleLogonResult(SteamUser.LoggedOnCallback logonResult) return false; } - var loginKeyExpired = _logonDetails.LoginKey != null && loggedOn.Result == EResult.InvalidPassword; - if (loginKeyExpired) - { - _userAccountStore.SessionTokens.Remove(_logonDetails.Username); - _logonDetails.LoginKey = null; - _logonDetails.Password = _ansiConsole.ReadPasswordAsync("Steam session expired! Password re-entry required! --> :").GetAwaiter().GetResult(); - return false; - } - if (loggedOn.Result == EResult.InvalidPassword) { _failedLogonAttempts++; @@ -247,44 +255,6 @@ private bool HandleLogonResult(SteamUser.LoggedOnCallback logonResult) return true; } - private bool _receivedLoginKey; - - /// - /// After a successful login, Steam will return a "Login Key", which is essentially a session token. - /// This "Login Key" will be used in subsequent logins, which will allow the user to login again without providing a password. - /// - /// Steam appears to have some sort of geo-location that detects if you are logging into your account - /// from a region that isn't your usual region (Ex. Logging onto a machine in Sydney, when you are from US East Coast). - /// If Steam detects this scenario, it will never issue a login key, presumable to minimize stolen passwords/accounts. - /// - private void TryWaitForLoginKey() - { - if (_logonDetails.LoginKey != null) - { - return; - } - - var totalWaitPeriod = DateTime.Now.AddSeconds(10); - while (!_receivedLoginKey) - { - if (DateTime.Now >= totalWaitPeriod) - { - _ansiConsole.LogMarkupLine(Red("Failed to save Steam session key. Steam account will not stay logged in...")); - return; - } - _callbackManager.RunWaitAllCallbacks(TimeSpan.FromMilliseconds(50)); - } - } - - private void LoginKeyCallback(SteamUser.LoginKeyCallback loginKey) - { - _userAccountStore.SessionTokens[_logonDetails.Username] = loginKey.LoginKey; - _userAccountStore.Save(); - - _steamUser.AcceptNewLoginKey(loginKey); - _receivedLoginKey = true; - } - private bool _disconnected = true; public void Disconnect() { @@ -309,39 +279,6 @@ public void Disconnect() #endregion - #region Other Auth Methods - - /// - /// The UpdateMachineAuth event will be triggered once the user has logged in with either Steam Guard or 2FA enabled. - /// This callback handler will save a "sentry file" for future logins, that will allow an existing Steam session to be reused, - /// without requiring a password. - /// - /// Despite the fact that this will be triggered for both Steam Guard + 2FA, the sentry file is only required for re-login when using an - /// account with Steam Guard enabled. - /// - private void UpdateMachineAuthCallback(SteamUser.UpdateMachineAuthCallback machineAuth) - { - _userAccountStore.SentryData[_logonDetails.Username] = machineAuth.Data; - _userAccountStore.Save(); - - var authResponse = new SteamUser.MachineAuthDetails - { - BytesWritten = machineAuth.BytesToWrite, - FileName = machineAuth.FileName, - FileSize = machineAuth.BytesToWrite, - Offset = machineAuth.Offset, - // should be the sha1 hash of the sentry file we just received - SentryFileHash = machineAuth.Data.ToSha1(), - OneTimePassword = machineAuth.OneTimePassword, - LastError = 0, - Result = EResult.OK, - JobID = machineAuth.JobID - }; - _steamUser.SendMachineAuthResponse(authResponse); - } - - #endregion - #region LoadAccountLicenses private bool _loadAccountLicensesIsRunning = true; @@ -378,4 +315,4 @@ public void Dispose() CdnClient.Dispose(); } } -} +} \ No newline at end of file diff --git a/SteamPrefill/Properties/GlobalUsings.cs b/SteamPrefill/Properties/GlobalUsings.cs index dc01bad8..f4778646 100644 --- a/SteamPrefill/Properties/GlobalUsings.cs +++ b/SteamPrefill/Properties/GlobalUsings.cs @@ -36,7 +36,6 @@ global using System.Reflection; global using System.Runtime.InteropServices; global using System.Runtime.Serialization; -global using System.Security.Authentication; global using System.Security.Cryptography; global using System.Text.Json; global using System.Text.Json.Serialization; @@ -47,4 +46,6 @@ global using AnsiConsoleExtensions = LancachePrefill.Common.Extensions.AnsiConsoleExtensions; global using PicsProductInfo = SteamKit2.SteamApps.PICSProductInfoCallback.PICSProductInfo; global using Architecture = SteamPrefill.Models.Enums.Architecture; -global using OperatingSystem = SteamPrefill.Models.Enums.OperatingSystem; \ No newline at end of file +global using OperatingSystem = SteamPrefill.Models.Enums.OperatingSystem; +global using SteamKit2.Authentication; +global using AuthenticationException = System.Security.Authentication.AuthenticationException; \ No newline at end of file diff --git a/SteamPrefill/Settings/UserAccountStore.cs b/SteamPrefill/Settings/UserAccountStore.cs index 5b65e2fb..57473671 100644 --- a/SteamPrefill/Settings/UserAccountStore.cs +++ b/SteamPrefill/Settings/UserAccountStore.cs @@ -1,23 +1,18 @@ -namespace SteamPrefill.Settings +using System.IdentityModel.Tokens.Jwt; + +namespace SteamPrefill.Settings { /// - /// Keeps track of the session tokens returned by Steam, that allow for subsequent logins without passwords. + /// Keeps track of the auth tokens (JWT) returned by Steam, that allow for subsequent logins without passwords. /// [ProtoContract(SkipConstructor = true)] public sealed class UserAccountStore { - /// - /// SentryData is returned by Steam when logging in with Steam Guard w\ email. - /// This data is required to be passed along in every subsequent login, in order to re-use an existing session. - /// + //TODO deprecated, remove in the future, say 2023/07/01 [ProtoMember(1)] public Dictionary SentryData { get; private set; } - /// - /// Upon a successful login to Steam, a "Login Key" will be returned to use on subsequent logins. - /// This login key can be considered a "session token", and can be used on subsequent logins to avoid entering a password. - /// These keys will be unique to each user. - /// + //TODO deprecated, remove in the future, say 2023/07/01 [ProtoMember(2)] public Dictionary SessionTokens { get; private set; } @@ -32,6 +27,12 @@ public sealed class UserAccountStore [ProtoMember(4)] public uint? SessionId { get; private set; } + /// + /// Steam has switched over to using JWT tokens for authorization. + /// + [ProtoMember(5)] + public string AccessToken { get; set; } + [SuppressMessage("Security", "CA5394:Random is an insecure RNG", Justification = "Security doesn't matter here, as all that is needed is a unique id.")] private UserAccountStore() { @@ -42,6 +43,12 @@ private UserAccountStore() SessionId = (uint)random.Next(0, 16384); } + /// + /// Gets the current user's username, if they have already entered it before. + /// If they have not yet entered it, they will be prompted to do so. + /// + /// Will timeout after 30 seconds of no user activity. + /// public async Task GetUsernameAsync(IAnsiConsole ansiConsole) { if (!String.IsNullOrEmpty(CurrentUsername)) @@ -53,6 +60,20 @@ public async Task GetUsernameAsync(IAnsiConsole ansiConsole) return CurrentUsername; } + public bool AccessTokenIsValid() + { + if (String.IsNullOrEmpty(AccessToken)) + { + return false; + } + + var parsedToken = new JwtSecurityToken(AccessToken); + + // Tokens seem to be valid for ~6 months. We're going to add a bit of "buffer" (1 day) to make sure that new tokens are request prior to expiration + var tokenHasExpired = DateTimeOffset.Now.DateTime.AddDays(1) < parsedToken.ValidTo; + return tokenHasExpired; + } + private async Task PromptForUsernameAsync(IAnsiConsole ansiConsole) { return await Task.Run(() => @@ -67,6 +88,8 @@ private async Task PromptForUsernameAsync(IAnsiConsole ansiConsole) }); } + #region Serialization + public static UserAccountStore LoadFromFile() { if (!File.Exists(AppConfig.AccountSettingsStorePath)) @@ -75,14 +98,16 @@ public static UserAccountStore LoadFromFile() } using var fileStream = File.Open(AppConfig.AccountSettingsStorePath, FileMode.Open, FileAccess.Read); - var userAccountStore = Serializer.Deserialize(fileStream); + var userAccountStore = ProtoBuf.Serializer.Deserialize(fileStream); return userAccountStore; } public void Save() { using var fs = File.Open(AppConfig.AccountSettingsStorePath, FileMode.Create, FileAccess.Write); - Serializer.Serialize(fs, this); + ProtoBuf.Serializer.Serialize(fs, this); } + + #endregion } -} +} \ No newline at end of file diff --git a/SteamPrefill/SteamPrefill.csproj b/SteamPrefill/SteamPrefill.csproj index edc50da9..6be01f21 100644 --- a/SteamPrefill/SteamPrefill.csproj +++ b/SteamPrefill/SteamPrefill.csproj @@ -49,8 +49,15 @@ - - + + + + + + + ..\lib\SteamKit2.dll + + diff --git a/lib/SteamKit2.dll b/lib/SteamKit2.dll new file mode 100644 index 00000000..904ca2a1 Binary files /dev/null and b/lib/SteamKit2.dll differ