Skip to content

Commit

Permalink
Closes #3043 (#3044)
Browse files Browse the repository at this point in the history
* Implement support for access tokens

A bit more work and testing is needed

* Make ValidUntil computed, fix netf, among others

* netf fixes as always

* Allow AWH to forcefully refresh session

* Unify access token lifetime
  • Loading branch information
JustArchi committed Oct 19, 2023
1 parent 4106c5f commit d571cd9
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 118 deletions.
1 change: 1 addition & 0 deletions ArchiSteamFarm/ArchiSteamFarm.csproj
Expand Up @@ -21,6 +21,7 @@
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" />
<PackageReference Include="System.Composition" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="System.Linq.Async" />
<PackageReference Include="zxcvbn-core" />
</ItemGroup>
Expand Down
18 changes: 18 additions & 0 deletions ArchiSteamFarm/Core/Utilities.cs
Expand Up @@ -24,6 +24,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net;
Expand All @@ -48,6 +49,8 @@ public static class Utilities {
// normally we'd just use words like "steam" and "farm", but the library we're currently using is a bit iffy about banned words, so we need to also add combinations such as "steamfarm"
private static readonly ImmutableHashSet<string> ForbiddenPasswordPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "archisteamfarm", "archi", "steam", "farm", "archisteam", "archifarm", "steamfarm", "asf", "asffarm", "password");

private static readonly JwtSecurityTokenHandler JwtSecurityTokenHandler = new();

[PublicAPI]
public static string GetArgsAsText(string[] args, byte argsToSkip, string delimiter) {
ArgumentNullException.ThrowIfNull(args);
Expand Down Expand Up @@ -187,6 +190,21 @@ public static class Utilities {
return (text.Length % 2 == 0) && text.All(Uri.IsHexDigit);
}

[PublicAPI]
public static JwtSecurityToken? ReadJwtToken(string token) {
if (string.IsNullOrEmpty(token)) {
throw new ArgumentNullException(nameof(token));
}

try {
return JwtSecurityTokenHandler.ReadJwtToken(token);
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);

return null;
}
}

[PublicAPI]
public static IList<INode> SelectNodes(this IDocument document, string xpath) {
ArgumentNullException.ThrowIfNull(document);
Expand Down
201 changes: 167 additions & 34 deletions ArchiSteamFarm/Steam/Bot.cs
Expand Up @@ -27,6 +27,7 @@
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net.Http;
Expand Down Expand Up @@ -67,6 +68,7 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
private const byte LoginCooldownInMinutes = 25; // Captcha disappears after around 20 minutes, so we make it 25
private const uint LoginID = 1242; // This must be the same for all ASF bots and all ASF processes
private const byte MaxLoginFailures = WebBrowser.MaxTries; // Max login failures in a row before we determine that our credentials are invalid (because Steam wrongly returns those, of course)course)
private const byte MinimumAccessTokenValidityMinutes = 10;
private const byte RedeemCooldownInHours = 1; // 1 hour since first redeem attempt, this is a limitation enforced by Steam

[PublicAPI]
Expand Down Expand Up @@ -229,11 +231,13 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
internal bool PlayingBlocked { get; private set; }
internal bool PlayingWasBlocked { get; private set; }

private DateTime? AccessTokenValidUntil;
private string? AuthCode;

[JsonProperty]
private string? AvatarHash;

private string? BackingAccessToken;
private Timer? ConnectionFailureTimer;
private bool FirstTradeSent;
private Timer? GamesRedeemerInBackgroundTimer;
Expand All @@ -244,13 +248,39 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
private ulong MasterChatGroupID;
private Timer? PlayingWasBlockedTimer;
private bool ReconnectOnUserInitiated;
private string? RefreshToken;
private Timer? RefreshTokensTimer;
private bool SendCompleteTypesScheduled;
private Timer? SendItemsTimer;
private bool SteamParentalActive;
private SteamSaleEvent? SteamSaleEvent;
private Timer? TradeCheckTimer;
private string? TwoFactorCode;

private string? AccessToken {
get => BackingAccessToken;

set {
AccessTokenValidUntil = null;
BackingAccessToken = value;

if (string.IsNullOrEmpty(value)) {
return;
}

// ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework
JwtSecurityToken? jwtToken = Utilities.ReadJwtToken(value!);

if (jwtToken == null) {
return;
}

if (jwtToken.ValidTo > DateTime.MinValue) {
AccessTokenValidUntil = jwtToken.ValidTo;
}
}
}

private Bot(string botName, BotConfig botConfig, BotDatabase botDatabase) {
BotName = !string.IsNullOrEmpty(botName) ? botName : throw new ArgumentNullException(nameof(botName));
BotConfig = botConfig ?? throw new ArgumentNullException(nameof(botConfig));
Expand Down Expand Up @@ -357,6 +387,7 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
ConnectionFailureTimer?.Dispose();
GamesRedeemerInBackgroundTimer?.Dispose();
PlayingWasBlockedTimer?.Dispose();
RefreshTokensTimer?.Dispose();
SendItemsTimer?.Dispose();
SteamSaleEvent?.Dispose();
TradeCheckTimer?.Dispose();
Expand Down Expand Up @@ -390,6 +421,10 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
await PlayingWasBlockedTimer.DisposeAsync().ConfigureAwait(false);
}

if (RefreshTokensTimer != null) {
await RefreshTokensTimer.DisposeAsync().ConfigureAwait(false);
}

if (SendItemsTimer != null) {
await SendItemsTimer.DisposeAsync().ConfigureAwait(false);
}
Expand Down Expand Up @@ -1492,32 +1527,55 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
await PluginsCore.OnBotFarmingStopped(this).ConfigureAwait(false);
}

internal async Task<bool> RefreshSession() {
internal async Task<bool> RefreshWebSession(bool force = false) {
if (!IsConnectedAndLoggedOn) {
return false;
}

SteamUser.WebAPIUserNonceCallback callback;
DateTime now = DateTime.UtcNow;

try {
callback = await SteamUser.RequestWebAPIUserNonce().ToLongRunningTask().ConfigureAwait(false);
} catch (Exception e) {
ArchiLogger.LogGenericWarningException(e);
if (!force && !string.IsNullOrEmpty(AccessToken) && AccessTokenValidUntil.HasValue && (AccessTokenValidUntil.Value > now.AddMinutes(MinimumAccessTokenValidityMinutes))) {
// We can use the tokens we already have
if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, AccessToken!, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) {
InitRefreshTokensTimer(AccessTokenValidUntil.Value);

return true;
}
}

// We need to refresh our session, access token is no longer valid
BotDatabase.AccessToken = AccessToken = null;

if (string.IsNullOrEmpty(RefreshToken)) {
// Without refresh token we can't get fresh access tokens, relog needed
await Connect(true).ConfigureAwait(false);

return false;
}

if (string.IsNullOrEmpty(callback.Nonce)) {
CAuthentication_AccessToken_GenerateForApp_Response? response = await ArchiHandler.GenerateAccessTokens(RefreshToken!).ConfigureAwait(false);

if (response == null) {
// The request has failed, in almost all cases this means our refresh token is no longer valid, relog needed
BotDatabase.RefreshToken = RefreshToken = null;

await Connect(true).ConfigureAwait(false);

return false;
}

if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.Nonce, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) {
// TODO: Handle update of refresh token with next SK2 release
UpdateTokens(response.access_token, RefreshToken!);

if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, response.access_token, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) {
InitRefreshTokensTimer(AccessTokenValidUntil ?? now.AddDays(1));

return true;
}

// We got the tokens, but failed to authorize? Purge them just to be sure and reconnect
BotDatabase.AccessToken = AccessToken = null;

await Connect(true).ConfigureAwait(false);

return false;
Expand Down Expand Up @@ -2274,6 +2332,19 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
WalletBalance = 0;
WalletCurrency = ECurrencyCode.Invalid;

AccessToken = BotDatabase.AccessToken;
RefreshToken = BotDatabase.RefreshToken;

if (BotConfig.PasswordFormat.HasTransformation()) {
if (!string.IsNullOrEmpty(AccessToken)) {
AccessToken = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, AccessToken!).ConfigureAwait(false);
}

if (!string.IsNullOrEmpty(RefreshToken)) {
AccessToken = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, RefreshToken!).ConfigureAwait(false);
}
}

CardsFarmer.SetInitialState(BotConfig.Paused);

if (SendItemsTimer != null) {
Expand Down Expand Up @@ -2344,6 +2415,42 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
);
}

private void InitRefreshTokensTimer(DateTime validUntil) {
if (validUntil == DateTime.MinValue) {
throw new ArgumentOutOfRangeException(nameof(validUntil));
}

if (validUntil == DateTime.MaxValue) {
// OK, tokens do not require refreshing
StopRefreshTokensTimer();

return;
}

TimeSpan delay = validUntil - DateTime.UtcNow;

// Start refreshing token before it's invalid
if (delay.TotalMinutes > MinimumAccessTokenValidityMinutes) {
delay -= TimeSpan.FromMinutes(MinimumAccessTokenValidityMinutes);
} else {
delay = TimeSpan.Zero;
}

// Timer can accept only dueTimes up to 2^32 - 2
uint dueTime = (uint) Math.Min(uint.MaxValue - 1, (ulong) delay.TotalMilliseconds);

if (RefreshTokensTimer == null) {
RefreshTokensTimer = new Timer(
OnRefreshTokensTimer,
null,
TimeSpan.FromMilliseconds(dueTime), // Delay
TimeSpan.FromMinutes(1) // Period
);
} else {
RefreshTokensTimer.Change(TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMinutes(1));
}
}

private void InitStart() {
if (!BotConfig.Enabled) {
ArchiLogger.LogGenericWarning(Strings.BotInstanceNotStartingBecauseDisabled);
Expand Down Expand Up @@ -2482,17 +2589,7 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
}
}

string? refreshToken = BotDatabase.RefreshToken;

if (!string.IsNullOrEmpty(refreshToken)) {
// Decrypt refreshToken if needed
if (BotConfig.PasswordFormat.HasTransformation()) {
// ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework
refreshToken = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, refreshToken!).ConfigureAwait(false);
}
}

if (!await InitLoginAndPassword(string.IsNullOrEmpty(refreshToken)).ConfigureAwait(false)) {
if (!await InitLoginAndPassword(string.IsNullOrEmpty(RefreshToken)).ConfigureAwait(false)) {
Stop();

return;
Expand Down Expand Up @@ -2537,7 +2634,7 @@ public sealed class Bot : IAsyncDisposable, IDisposable {

InitConnectionFailureTimer();

if (string.IsNullOrEmpty(refreshToken)) {
if (string.IsNullOrEmpty(RefreshToken)) {
AuthPollResult pollResult;

try {
Expand Down Expand Up @@ -2569,19 +2666,15 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
return;
}

refreshToken = pollResult.RefreshToken;

if (BotConfig.UseLoginKeys) {
BotDatabase.RefreshToken = BotConfig.PasswordFormat.HasTransformation() ? ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, refreshToken) : refreshToken;

if (!string.IsNullOrEmpty(pollResult.NewGuardData)) {
BotDatabase.SteamGuardData = pollResult.NewGuardData;
}
if (!string.IsNullOrEmpty(pollResult.NewGuardData) && BotConfig.UseLoginKeys) {
BotDatabase.SteamGuardData = pollResult.NewGuardData;
}

UpdateTokens(pollResult.AccessToken, pollResult.RefreshToken);
}

SteamUser.LogOnDetails logOnDetails = new() {
AccessToken = refreshToken,
AccessToken = RefreshToken,
CellID = ASF.GlobalDatabase?.CellID,
LoginID = LoginID,
SentryFileHash = sentryFileHash,
Expand All @@ -2606,6 +2699,7 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
HeartBeatFailures = 0;
StopConnectionFailureTimer();
StopPlayingWasBlockedTimer();
StopRefreshTokensTimer();

ArchiLogger.LogGenericInfo(Strings.BotDisconnected);

Expand Down Expand Up @@ -3087,11 +3181,9 @@ public sealed class Bot : IAsyncDisposable, IDisposable {

ArchiWebHandler.OnVanityURLChanged(callback.VanityURL);

// ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework
if (string.IsNullOrEmpty(callback.WebAPIUserNonce) || !await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.WebAPIUserNonce!, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) {
if (!await RefreshSession().ConfigureAwait(false)) {
return;
}
// Establish web session
if (!await RefreshWebSession().ConfigureAwait(false)) {
return;
}

// Pre-fetch API key for future usage if possible
Expand Down Expand Up @@ -3236,6 +3328,15 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
await CheckOccupationStatus().ConfigureAwait(false);
}

private async void OnRefreshTokensTimer(object? state = null) {
if (AccessTokenValidUntil.HasValue && (AccessTokenValidUntil.Value > DateTime.UtcNow.AddMinutes(MinimumAccessTokenValidityMinutes))) {
// We don't need to refresh just yet
InitRefreshTokensTimer(AccessTokenValidUntil.Value);
}

await RefreshWebSession().ConfigureAwait(false);
}

private async void OnSendItemsTimer(object? state = null) => await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false);

private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) {
Expand Down Expand Up @@ -3708,6 +3809,38 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
PlayingWasBlockedTimer = null;
}

private void StopRefreshTokensTimer() {
if (RefreshTokensTimer == null) {
return;
}

RefreshTokensTimer.Dispose();
RefreshTokensTimer = null;
}

private void UpdateTokens(string accessToken, string refreshToken) {
if (string.IsNullOrEmpty(accessToken)) {
throw new ArgumentNullException(nameof(accessToken));
}

if (string.IsNullOrEmpty(refreshToken)) {
throw new ArgumentNullException(nameof(refreshToken));
}

AccessToken = accessToken;
RefreshToken = refreshToken;

if (BotConfig.UseLoginKeys) {
if (BotConfig.PasswordFormat.HasTransformation()) {
BotDatabase.AccessToken = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, accessToken);
BotDatabase.RefreshToken = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, refreshToken);
} else {
BotDatabase.AccessToken = accessToken;
BotDatabase.RefreshToken = refreshToken;
}
}
}

private (bool IsSteamParentalEnabled, string? SteamParentalCode) ValidateSteamParental(ParentalSettings settings, string? steamParentalCode = null, bool allowGeneration = true) {
ArgumentNullException.ThrowIfNull(settings);

Expand Down

0 comments on commit d571cd9

Please sign in to comment.