diff --git a/DragonFruit.Six.Api.Tests/Data/AccountLevelTests.cs b/DragonFruit.Six.Api.Tests/Data/AccountLevelTests.cs index 3aeb2062..b0cebbf8 100644 --- a/DragonFruit.Six.Api.Tests/Data/AccountLevelTests.cs +++ b/DragonFruit.Six.Api.Tests/Data/AccountLevelTests.cs @@ -14,6 +14,8 @@ public class AccountLevelTests : Dragon6ApiTest [TestCaseSource(nameof(Accounts))] public async Task GetAccountLevel(UbisoftAccount account) { + Assert.Inconclusive("Tests currently suspended until dragon6-tokens updated"); + var level = await Client.GetAccountLevelAsync(account).ConfigureAwait(false); if (level.Level == 0) diff --git a/DragonFruit.Six.Api/Accounts/Requests/AccountLevelRequest.cs b/DragonFruit.Six.Api/Accounts/Requests/AccountLevelRequest.cs index 9184ddf9..7d1c44f3 100644 --- a/DragonFruit.Six.Api/Accounts/Requests/AccountLevelRequest.cs +++ b/DragonFruit.Six.Api/Accounts/Requests/AccountLevelRequest.cs @@ -4,6 +4,7 @@ using DragonFruit.Data.Parameters; using DragonFruit.Six.Api.Accounts.Entities; using DragonFruit.Six.Api.Accounts.Enums; +using DragonFruit.Six.Api.Enums; using JetBrains.Annotations; namespace DragonFruit.Six.Api.Accounts.Requests @@ -11,6 +12,7 @@ namespace DragonFruit.Six.Api.Accounts.Requests public class AccountLevelRequest : UbiApiRequest { public override string Path => Platform.CrossPlatform.SpaceUrl(1) + "/title/r6s/rewards/public_profile"; + protected override UbisoftService? RequiredTokenSource => UbisoftService.RainbowSixClient; public AccountLevelRequest(UbisoftAccount account) { diff --git a/DragonFruit.Six.Api/Authentication/Entities/ClientTokenAccessor.cs b/DragonFruit.Six.Api/Authentication/Entities/ClientTokenAccessor.cs new file mode 100644 index 00000000..ab7eba7e --- /dev/null +++ b/DragonFruit.Six.Api/Authentication/Entities/ClientTokenAccessor.cs @@ -0,0 +1,70 @@ +// Dragon6 API Copyright DragonFruit Network +// Licensed under Apache-2. Refer to the LICENSE file for more info + +using System; +using System.Threading.Tasks; +using DragonFruit.Six.Api.Enums; +using DragonFruit.Six.Api.Exceptions; +using Nito.AsyncEx; + +namespace DragonFruit.Six.Api.Authentication.Entities +{ + public class ClientTokenAccessor + { + private readonly AsyncLock _accessSync; + private readonly UbisoftService _service; + private readonly Func> _fetchTokenDelegate; + + private ClientTokenInjector _currentToken; + + internal ClientTokenAccessor(UbisoftService service, Func> fetchTokenDelegate) + { + _accessSync = new AsyncLock(); + + _service = service; + _fetchTokenDelegate = fetchTokenDelegate; + } + + /// + /// Gets a containing a valid token that can be injected into http requests + /// + public async ValueTask GetInjector() + { + if (_currentToken?.Expired == false) + { + return _currentToken; + } + + using (await _accessSync.LockAsync().ConfigureAwait(false)) + { + // check again in case of a backlog + if (_currentToken?.Expired == false) + { + return _currentToken; + } + + for (int i = 0; i < 2; i++) + { + var token = await _fetchTokenDelegate.Invoke(_service, _currentToken?.Token.SessionId).ConfigureAwait(false); + _currentToken = new ClientTokenInjector(token); + + if (!_currentToken.Expired) + { + return _currentToken; + } + } + + throw new InvalidTokenException(_currentToken?.Token); + } + } + + /// + /// Gets a valid + /// + public async ValueTask GetToken() + { + var injector = await GetInjector().ConfigureAwait(false); + return injector.Token; + } + } +} diff --git a/DragonFruit.Six.Api/Authentication/Entities/ClientTokenInjector.cs b/DragonFruit.Six.Api/Authentication/Entities/ClientTokenInjector.cs index b37f0453..d8bab488 100644 --- a/DragonFruit.Six.Api/Authentication/Entities/ClientTokenInjector.cs +++ b/DragonFruit.Six.Api/Authentication/Entities/ClientTokenInjector.cs @@ -17,9 +17,9 @@ internal ClientTokenInjector(IUbisoftToken token) _tokenExpiryOffset = new DateTime(Math.Max(Token.Expiry.Ticks - 3000000000, 0), DateTimeKind.Utc); } - internal bool Expired => _tokenExpiryOffset < DateTime.UtcNow; + internal IUbisoftToken Token { get; } - public IUbisoftToken Token { get; } + internal bool Expired => _tokenExpiryOffset < DateTime.UtcNow; /// /// Injects Ubisoft authentication headers into the provided @@ -32,6 +32,7 @@ public void Inject(ApiRequest request) // modern api requests need both the session id and the expiration headers added request.WithHeader("Expiration", Token.Expiry.ToString("O")); + request.WithHeader(UbisoftIdentifiers.UbiAppIdHeader, Token.AppId); request.WithHeader(UbisoftIdentifiers.UbiSessionIdHeader, Token.SessionId); } } diff --git a/DragonFruit.Six.Api/Authentication/Entities/Dragon6Token.cs b/DragonFruit.Six.Api/Authentication/Entities/Dragon6Token.cs index 846daa6d..e51fc51b 100644 --- a/DragonFruit.Six.Api/Authentication/Entities/Dragon6Token.cs +++ b/DragonFruit.Six.Api/Authentication/Entities/Dragon6Token.cs @@ -11,6 +11,12 @@ namespace DragonFruit.Six.Api.Authentication.Entities /// public class Dragon6Token : IUbisoftToken { + /// + /// App-Id must be set client side. + /// + [JsonProperty("appId")] + public string AppId { get; set; } + [JsonProperty("token")] public string Token { get; set; } @@ -25,6 +31,7 @@ public class Dragon6Token : IUbisoftToken /// public static Dragon6Token From(UbisoftToken token) => new() { + AppId = token.AppId, Token = token.Token, Expiry = token.Expiry, SessionId = token.SessionId diff --git a/DragonFruit.Six.Api/Authentication/Entities/IUbisoftToken.cs b/DragonFruit.Six.Api/Authentication/Entities/IUbisoftToken.cs index 72941b59..e606ef1c 100644 --- a/DragonFruit.Six.Api/Authentication/Entities/IUbisoftToken.cs +++ b/DragonFruit.Six.Api/Authentication/Entities/IUbisoftToken.cs @@ -10,6 +10,8 @@ namespace DragonFruit.Six.Api.Authentication.Entities /// public interface IUbisoftToken { + string AppId { get; } + string Token { get; } string SessionId { get; } diff --git a/DragonFruit.Six.Api/Authentication/Entities/UbisoftToken.cs b/DragonFruit.Six.Api/Authentication/Entities/UbisoftToken.cs index 91359f3b..176eeec2 100644 --- a/DragonFruit.Six.Api/Authentication/Entities/UbisoftToken.cs +++ b/DragonFruit.Six.Api/Authentication/Entities/UbisoftToken.cs @@ -11,6 +11,12 @@ namespace DragonFruit.Six.Api.Authentication.Entities /// public class UbisoftToken : IUbisoftToken { + /// + /// App-Id must be set client side. + /// + [JsonProperty("appId")] + public string AppId { get; set; } + [JsonProperty("expiration")] public DateTime Expiry { get; set; } diff --git a/DragonFruit.Six.Api/Authentication/Requests/UbisoftTokenRequest.cs b/DragonFruit.Six.Api/Authentication/Requests/UbisoftTokenRequest.cs index 9e801f27..dba64dae 100644 --- a/DragonFruit.Six.Api/Authentication/Requests/UbisoftTokenRequest.cs +++ b/DragonFruit.Six.Api/Authentication/Requests/UbisoftTokenRequest.cs @@ -1,11 +1,11 @@ // Dragon6 API Copyright DragonFruit Network // Licensed under Apache-2. Refer to the LICENSE file for more info -using System; using System.Net.Http; using System.Text; using DragonFruit.Data; using DragonFruit.Data.Extensions; +using DragonFruit.Six.Api.Enums; namespace DragonFruit.Six.Api.Authentication.Requests { @@ -20,11 +20,10 @@ public sealed class UbisoftTokenRequest : ApiRequest // tokens need an empty request body in UTF8, with app/json type... protected override HttpContent BodyContent => new StringContent(string.Empty, Encoding.UTF8, "application/json"); - internal UbisoftTokenRequest() + public UbisoftTokenRequest(UbisoftService service, string authentication) { + this.WithHeader(UbisoftIdentifiers.UbiAppIdHeader, service.AppId()); + this.WithAuthHeader($"Basic {authentication}"); } - - public static UbisoftTokenRequest FromEncodedCredentials(string basicAuth) => new UbisoftTokenRequest().WithAuthHeader($"Basic {basicAuth}"); - public static UbisoftTokenRequest FromUsername(string username, string password) => FromEncodedCredentials(Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}"))); } } diff --git a/DragonFruit.Six.Api/Authentication/UbisoftTokenExtensions.cs b/DragonFruit.Six.Api/Authentication/UbisoftTokenExtensions.cs index 0a74e08a..a32189f8 100644 --- a/DragonFruit.Six.Api/Authentication/UbisoftTokenExtensions.cs +++ b/DragonFruit.Six.Api/Authentication/UbisoftTokenExtensions.cs @@ -1,11 +1,14 @@ // Dragon6 API Copyright DragonFruit Network // Licensed under Apache-2. Refer to the LICENSE file for more info +using System; +using System.Text; using System.Threading; using System.Threading.Tasks; using DragonFruit.Data; using DragonFruit.Six.Api.Authentication.Entities; using DragonFruit.Six.Api.Authentication.Requests; +using DragonFruit.Six.Api.Enums; namespace DragonFruit.Six.Api.Authentication { @@ -15,66 +18,49 @@ public static class UbisoftTokenExtensions /// Gets a session token for the user credentials provided. /// /// - /// You should store this in some form of persistant storage, as requesting these + /// You should store this in some form of persistent storage, as requesting these /// too many times will result in a cooldown which is reset every time you try to access the resource /// during said cooldown /// /// The to use /// The base64 encoded string in the format username:password + /// to get the token for. If the is a , this is optional /// Optional cancellation token - public static UbisoftToken GetUbiToken(this ApiClient client, string loginString, CancellationToken token = default) + public static async Task GetUbiTokenAsync(this ApiClient client, string loginString, UbisoftService? service = null, CancellationToken token = default) { - return client.Perform(UbisoftTokenRequest.FromEncodedCredentials(loginString), token); - } + if (client is Dragon6Client d6Client) + { + service ??= d6Client.DefaultService; + } - /// - /// Gets a session token for the user credentials provided. - /// - /// - /// You should store this in some form of persistant storage, as requesting these - /// too many times will result in a cooldown which is reset every time you try to access the resource - /// during said cooldown - /// - /// The to use - /// The base64 encoded string in the format username:password - /// Optional cancellation token - public static Task GetUbiTokenAsync(this ApiClient client, string loginString, CancellationToken token = default) - { - return client.PerformAsync(UbisoftTokenRequest.FromEncodedCredentials(loginString), token); - } + if (!service.HasValue) + { + throw new ArgumentException($"{nameof(service)} must be non-null when used with a client that does not inherit from {nameof(Dragon6Client)}"); + } - /// - /// Gets a session token for the user credentials provided. - /// - /// - /// You should store this in some form of persistant storage, as requesting these - /// too many times will result in a cooldown which is reset every time you try to access the resource - /// during said cooldown - /// - /// The to use - /// The username to use - /// The password to use - /// Optional cancellation token - public static UbisoftToken GetUbiToken(this ApiClient client, string username, string password, CancellationToken token = default) - { - return client.Perform(UbisoftTokenRequest.FromUsername(username, password), token); + var ubisoftToken = await client.PerformAsync(new UbisoftTokenRequest(service.Value, loginString), token).ConfigureAwait(false); + ubisoftToken.AppId = service.Value.AppId(); + + return ubisoftToken; } /// /// Gets a session token for the user credentials provided. /// /// - /// You should store this in some form of persistant storage, as requesting these + /// You should store this in some form of persistent storage, as requesting these /// too many times will result in a cooldown which is reset every time you try to access the resource /// during said cooldown /// /// The to use /// The username to use /// The password to use + /// to get the token for. If the is a , this is optional /// Optional cancellation token - public static Task GetUbiTokenAsync(this ApiClient client, string username, string password, CancellationToken token = default) + public static Task GetUbiTokenAsync(this ApiClient client, string username, string password, UbisoftService? service = null, CancellationToken token = default) { - return client.PerformAsync(UbisoftTokenRequest.FromUsername(username, password), token); + var basicLogin = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + return GetUbiTokenAsync(client, basicLogin, service, token); } } } diff --git a/DragonFruit.Six.Api/Dragon6Client.cs b/DragonFruit.Six.Api/Dragon6Client.cs index b7f1fa86..cc29d372 100644 --- a/DragonFruit.Six.Api/Dragon6Client.cs +++ b/DragonFruit.Six.Api/Dragon6Client.cs @@ -2,6 +2,7 @@ // Licensed under Apache-2. Refer to the LICENSE file for more info using System; +using System.Collections.Concurrent; using System.Globalization; using System.Net; using System.Net.Http; @@ -12,18 +13,15 @@ using DragonFruit.Six.Api.Enums; using DragonFruit.Six.Api.Exceptions; using DragonFruit.Six.Api.Legacy.Utils; -using Nito.AsyncEx; namespace DragonFruit.Six.Api { public abstract class Dragon6Client : ApiClient { - private ClientTokenInjector _access; - private readonly AsyncLock _accessSync = new(); + private readonly ConcurrentDictionary _access = new(); - protected Dragon6Client(string userAgent = null, UbisoftService app = UbisoftService.RainbowSixClient) + protected Dragon6Client(string userAgent = null) { - SetUbiAppId(app); UserAgent = userAgent ?? "Dragon6-API"; Serializer.Configure(o => o.Serializer.Culture = CultureInfo.InvariantCulture); } @@ -33,20 +31,20 @@ static Dragon6Client() LegacyStatsMapping.InitialiseStatsBuckets(); } + /// + /// The default to use in requests. Some APIs may override this and require a specific service to be used. + /// + public UbisoftService DefaultService { get; set; } = UbisoftService.RainbowSix; + /// /// Defines the procedure for retrieving a for the client to use. /// + /// The service to fetch a token for /// The last recorded session id. This should be used to check if a new session should be created from the server /// /// It is recommended to store the token to a file and try to retrieve from there before resorting to the online systems, as accounts can be blocked due to rate-limits /// - protected abstract Task GetToken(string sessionId); - - /// - /// Updates the Ubi-AppId header to be supplied to each request. - /// Defaults to - /// - public void SetUbiAppId(UbisoftService service) => Headers[UbisoftIdentifiers.UbiAppIdHeader] = service.AppId(); + protected abstract Task GetToken(UbisoftService service, string sessionId); /// /// Handles the response before trying to deserialize it. @@ -63,7 +61,7 @@ protected override async Task ValidateAndProcess(HttpResponseMessage respo throw new UbisoftErrorException(response.StatusCode, error); case HttpStatusCode.Unauthorized: - throw new InvalidTokenException(_access.Token); + throw new InvalidTokenException(response.RequestMessage.Headers.Authorization.ToString()); case HttpStatusCode.BadRequest: throw new ArgumentException("Request was poorly formed. Check the properties passed and try again"); @@ -76,34 +74,9 @@ protected override async Task ValidateAndProcess(HttpResponseMessage respo } } - protected internal async ValueTask RequestToken() - { - if (_access?.Expired == false) - { - return _access; - } - - using (await _accessSync.LockAsync().ConfigureAwait(false)) - { - // check again in case of a backlog - if (_access?.Expired == false) - { - return _access; - } - - for (int i = 0; i < 2; i++) - { - var token = await GetToken(_access?.Token.SessionId).ConfigureAwait(false); - _access = new ClientTokenInjector(token); - - if (!_access.Expired) - { - return _access; - } - } - - throw new InvalidTokenException(_access?.Token); - } - } + /// + /// Gets a for the requested + /// + protected internal ClientTokenAccessor GetServiceAccessToken(UbisoftService service) => _access.GetOrAdd(service, s => new ClientTokenAccessor(s, GetToken)); } } diff --git a/DragonFruit.Six.Api/Exceptions/InvalidTokenException.cs b/DragonFruit.Six.Api/Exceptions/InvalidTokenException.cs index 726a6876..fefc6ecd 100644 --- a/DragonFruit.Six.Api/Exceptions/InvalidTokenException.cs +++ b/DragonFruit.Six.Api/Exceptions/InvalidTokenException.cs @@ -14,6 +14,15 @@ public InvalidTokenException(IUbisoftToken token) Token = token; } + public InvalidTokenException(string token) + : base("The Token has expired or is invalid for this request") + { + Token = new Dragon6Token + { + Token = token + }; + } + public IUbisoftToken Token { get; } } } diff --git a/DragonFruit.Six.Api/Seasonal/Requests/SeasonalStatsRecordRequest.cs b/DragonFruit.Six.Api/Seasonal/Requests/SeasonalStatsRecordRequest.cs index 1f4c0ed4..47cc6192 100644 --- a/DragonFruit.Six.Api/Seasonal/Requests/SeasonalStatsRecordRequest.cs +++ b/DragonFruit.Six.Api/Seasonal/Requests/SeasonalStatsRecordRequest.cs @@ -70,7 +70,7 @@ public SeasonalStatsRecordRequest(IEnumerable accounts, IEnumera /// This is left for legacy seasons, which remain region-specific /// [QueryParameter("region_ids", EnumHandlingMode.StringLower)] - public Region Regions { get; set; } = Region.APAC | Region.EMEA | Region.NCSA; + public Region Regions { get; set; } = Region.EMEA; [QueryParameter("profile_ids", CollectionConversionMode.Concatenated)] private IEnumerable AccountIds => Accounts.Select(x => x.ProfileId); diff --git a/DragonFruit.Six.Api/Seasonal/SeasonStatsExtensions.cs b/DragonFruit.Six.Api/Seasonal/SeasonStatsExtensions.cs index daf2c84c..c83794f3 100644 --- a/DragonFruit.Six.Api/Seasonal/SeasonStatsExtensions.cs +++ b/DragonFruit.Six.Api/Seasonal/SeasonStatsExtensions.cs @@ -66,21 +66,24 @@ public static async Task> GetSeasonalStatsRec if (otherSeasons.Any()) { - var platformRequests = accounts.GroupBy(x => x.Platform).Select(x => new SeasonalStatsRecordRequest(x, otherSeasons, boards.Value)); - requests.AddRange(platformRequests); - } - - var seasonalStatsRequests = requests.Select(x => - { - if (regions.HasValue) + var platformRequests = accounts.GroupBy(x => x.Platform).Select(x => { - x.Regions = regions.Value; - } + var request = new SeasonalStatsRecordRequest(x, otherSeasons, boards.Value); + + if (regions.HasValue) + { + request.Regions = regions.Value; + } - return client.PerformAsync(x, token); - }); + return request; + }); + requests.AddRange(platformRequests); + } + + var seasonalStatsRequests = requests.Select(x => client.PerformAsync(x, token)); var seasonalStatsResponses = await Task.WhenAll(seasonalStatsRequests).ConfigureAwait(false); + return seasonalStatsResponses.SelectMany(x => x.SelectTokens("$..players_skill_records[*]")).Select(x => x.ToObject()).ToList(); } } diff --git a/DragonFruit.Six.Api/Services/Developer/Dragon6DeveloperClient.cs b/DragonFruit.Six.Api/Services/Developer/Dragon6DeveloperClient.cs index 52319ae8..5d85b4b9 100644 --- a/DragonFruit.Six.Api/Services/Developer/Dragon6DeveloperClient.cs +++ b/DragonFruit.Six.Api/Services/Developer/Dragon6DeveloperClient.cs @@ -4,6 +4,7 @@ using System; using System.Threading.Tasks; using DragonFruit.Six.Api.Authentication.Entities; +using DragonFruit.Six.Api.Enums; using Nito.AsyncEx; namespace DragonFruit.Six.Api.Services.Developer @@ -22,9 +23,13 @@ public Dragon6DeveloperClient(string clientId, string clientSecret, string scope _scopes = scopes; } - protected override async Task GetToken(string sessionId) + protected override async Task GetToken(UbisoftService service, string sessionId) { - return await PerformAsync(new Dragon6TokenRequest()).ConfigureAwait(false); + // todo change request based on service + var token = await PerformAsync(new Dragon6TokenRequest()).ConfigureAwait(false); + token.AppId = UbisoftService.NewStatsSite.AppId(); + + return token; } internal async ValueTask RequestDragonFruitAccessToken() diff --git a/DragonFruit.Six.Api/Services/Geolocation/Requests/UbisoftSelfGeolocationRequest.cs b/DragonFruit.Six.Api/Services/Geolocation/Requests/UbisoftSelfGeolocationRequest.cs index 302c154e..813b9925 100644 --- a/DragonFruit.Six.Api/Services/Geolocation/Requests/UbisoftSelfGeolocationRequest.cs +++ b/DragonFruit.Six.Api/Services/Geolocation/Requests/UbisoftSelfGeolocationRequest.cs @@ -20,11 +20,8 @@ public class UbisoftSelfGeolocationRequest : ApiRequest, IRequestExecutingCallba void IRequestExecutingCallback.OnRequestExecuting(ApiClient client) { - if (client is not Dragon6Client) - { - // can run on anything but needs a ubi-appid header if we're not using dragon6 client - this.WithHeader(UbisoftIdentifiers.UbiAppIdHeader, UbisoftService.NewStatsSite.AppId()); - } + var service = client is Dragon6Client d6Client ? d6Client.DefaultService : UbisoftService.NewStatsSite; + this.WithHeader(UbisoftIdentifiers.UbiAppIdHeader, service.AppId()); } } } diff --git a/DragonFruit.Six.Api/UbiApiRequest.cs b/DragonFruit.Six.Api/UbiApiRequest.cs index 95762a5c..d744874d 100644 --- a/DragonFruit.Six.Api/UbiApiRequest.cs +++ b/DragonFruit.Six.Api/UbiApiRequest.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using DragonFruit.Data; using DragonFruit.Data.Requests; +using DragonFruit.Six.Api.Enums; #nullable enable @@ -17,14 +18,19 @@ public abstract class UbiApiRequest : ApiRequest, IAsyncRequestExecutingCallback { protected override bool RequireAuth => true; + /// + /// Optional override to request a specific token source + /// + protected virtual UbisoftService? RequiredTokenSource => null; + async ValueTask IAsyncRequestExecutingCallback.OnRequestExecutingAsync(ApiClient client) { // all ubisoft api requests need authentication // the Dragon6Client caches auth tokens and allows the headers to be injected if (client is Dragon6Client d6Client) { - var token = await d6Client.RequestToken().ConfigureAwait(false); - token.Inject(this); + var injector = await d6Client.GetServiceAccessToken(RequiredTokenSource ?? d6Client.DefaultService).GetInjector().ConfigureAwait(false); + injector.Inject(this); } } }