Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switching over to using Steam's new JWT based authentication. Valve … #275

Merged
merged 1 commit into from
Mar 28, 2023
Merged
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
6 changes: 6 additions & 0 deletions SteamPrefill.Test/SteamPrefill.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
</ItemGroup>

<ItemGroup>
<!-- TODO remove this custom built SteamKit2 dll once the SteamKit team has published the auth changes -->
<Reference Include="SteamKit2">
<HintPath>..\lib\SteamKit2.dll</HintPath>
</Reference>
<!--<PackageReference Include="SteamKit2" Version="2.4.1" />-->

<ProjectReference Include="..\SteamPrefill\SteamPrefill.csproj" />
<Reference Include="Spectre.Console">
<HintPath>..\lib\Spectre.Console.dll</HintPath>
Expand Down
165 changes: 51 additions & 114 deletions SteamPrefill/Handlers/Steam/Steam3Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ public sealed class Steam3Session : IDisposable
public readonly SteamApps SteamAppsApi;
public readonly Client CdnClient;
public SteamUnifiedMessages.UnifiedService<IPlayer> unifiedPlayerService;

private readonly CallbackManager _callbackManager;

private SteamUser.LogOnDetails _logonDetails;
private readonly IAnsiConsole _ansiConsole;

private readonly UserAccountStore _userAccountStore;

public readonly LicenseManager LicenseManager;

public SteamID _steamId;
Expand Down Expand Up @@ -48,8 +46,6 @@ public Steam3Session(IAnsiConsole ansiConsole)
});

_callbackManager.Subscribe<SteamUser.LoggedOnCallback>(loggedOn => _loggedOnCallbackResult = loggedOn);
_callbackManager.Subscribe<SteamUser.UpdateMachineAuthCallback>(UpdateMachineAuthCallback);
_callbackManager.Subscribe<SteamUser.LoginKeyCallback>(LoginKeyCallback);
_callbackManager.Subscribe<SteamApps.LicenseListCallback>(LicenseListCallback);

CdnClient = new Client(_steamClient);
Expand All @@ -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();
});

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
{
Expand All @@ -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++;
Expand Down Expand Up @@ -247,44 +255,6 @@ private bool HandleLogonResult(SteamUser.LoggedOnCallback logonResult)
return true;
}

private bool _receivedLoginKey;

/// <summary>
/// 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.
/// </summary>
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()
{
Expand All @@ -309,39 +279,6 @@ public void Disconnect()

#endregion

#region Other Auth Methods

/// <summary>
/// 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.
/// </summary>
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;
Expand Down Expand Up @@ -378,4 +315,4 @@ public void Dispose()
CdnClient.Dispose();
}
}
}
}
5 changes: 3 additions & 2 deletions SteamPrefill/Properties/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
global using OperatingSystem = SteamPrefill.Models.Enums.OperatingSystem;
global using SteamKit2.Authentication;
global using AuthenticationException = System.Security.Authentication.AuthenticationException;
53 changes: 39 additions & 14 deletions SteamPrefill/Settings/UserAccountStore.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
namespace SteamPrefill.Settings
using System.IdentityModel.Tokens.Jwt;

namespace SteamPrefill.Settings
{
/// <summary>
/// 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.
/// </summary>
[ProtoContract(SkipConstructor = true)]
public sealed class UserAccountStore
{
/// <summary>
/// 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.
/// </summary>
//TODO deprecated, remove in the future, say 2023/07/01
[ProtoMember(1)]
public Dictionary<string, byte[]> SentryData { get; private set; }

/// <summary>
/// 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.
/// </summary>
//TODO deprecated, remove in the future, say 2023/07/01
[ProtoMember(2)]
public Dictionary<string, string> SessionTokens { get; private set; }

Expand All @@ -32,6 +27,12 @@ public sealed class UserAccountStore
[ProtoMember(4)]
public uint? SessionId { get; private set; }

/// <summary>
/// Steam has switched over to using JWT tokens for authorization.
/// </summary>
[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()
{
Expand All @@ -42,6 +43,12 @@ private UserAccountStore()
SessionId = (uint)random.Next(0, 16384);
}

/// <summary>
/// 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.
/// </summary>
public async Task<string> GetUsernameAsync(IAnsiConsole ansiConsole)
{
if (!String.IsNullOrEmpty(CurrentUsername))
Expand All @@ -53,6 +60,20 @@ public async Task<string> 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<string> PromptForUsernameAsync(IAnsiConsole ansiConsole)
{
return await Task.Run(() =>
Expand All @@ -67,6 +88,8 @@ private async Task<string> PromptForUsernameAsync(IAnsiConsole ansiConsole)
});
}

#region Serialization

public static UserAccountStore LoadFromFile()
{
if (!File.Exists(AppConfig.AccountSettingsStorePath))
Expand All @@ -75,14 +98,16 @@ public static UserAccountStore LoadFromFile()
}

using var fileStream = File.Open(AppConfig.AccountSettingsStorePath, FileMode.Open, FileAccess.Read);
var userAccountStore = Serializer.Deserialize<UserAccountStore>(fileStream);
var userAccountStore = ProtoBuf.Serializer.Deserialize<UserAccountStore>(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
}
}
}
Loading