diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index d4026b7679..4fcdc1e981 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -44,6 +44,16 @@ namespace Tgstation.Server.Client /// readonly List requestLoggers; + /// + /// Backing field for + /// + readonly ApiHeaders? tokenRefreshHeaders; + + /// + /// The for refreshes. + /// + readonly SemaphoreSlim semaphoreSlim; + /// /// Backing field for /// @@ -101,18 +111,25 @@ namespace Tgstation.Server.Client /// /// The value of /// The value of - /// The value of - public ApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders) + /// The value of + /// The value of + public ApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); Url = url ?? throw new ArgumentNullException(nameof(url)); headers = apiHeaders ?? throw new ArgumentNullException(nameof(apiHeaders)); + this.tokenRefreshHeaders = tokenRefreshHeaders; requestLoggers = new List(); + semaphoreSlim = new SemaphoreSlim(1); } /// - public void Dispose() => httpClient.Dispose(); + public void Dispose() + { + httpClient.Dispose(); + semaphoreSlim.Dispose(); + } /// /// Main request method @@ -122,9 +139,10 @@ namespace Tgstation.Server.Client /// The body of the request /// The method of the request /// The optional for the request + /// If this is a token refresh operation. /// The for the operation /// A resulting in the response on success - async Task RunRequest(string route, object? body, HttpMethod method, long? instanceId, CancellationToken cancellationToken) + async Task RunRequest(string route, object? body, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken) { if (route == null) throw new ArgumentNullException(nameof(route)); @@ -141,11 +159,25 @@ namespace Tgstation.Server.Client if (body != null) request.Content = new StringContent(JsonConvert.SerializeObject(body, serializerSettings), Encoding.UTF8, ApiHeaders.ApplicationJson); - headers.SetRequestHeaders(request.Headers, instanceId); + var headersToUse = tokenRefresh ? tokenRefreshHeaders! : headers; + headersToUse.SetRequestHeaders(request.Headers, instanceId); - await Task.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false); + // This is meant to be a gate against token refresh operations + await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); + if(!tokenRefresh) + semaphoreSlim.Release(); - response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + try + { + await Task.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false); + + response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + if (tokenRefresh) + semaphoreSlim.Release(); + } } using (response) @@ -155,7 +187,13 @@ namespace Tgstation.Server.Client var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) + { + if (!tokenRefresh + && response.StatusCode == HttpStatusCode.Unauthorized + && await RefreshToken(cancellationToken).ConfigureAwait(false)) + return await RunRequest(route, body, method, instanceId, false, cancellationToken).ConfigureAwait(false); HandleBadResponse(response, json); + } if (String.IsNullOrWhiteSpace(json)) json = JsonConvert.SerializeObject(new object()); @@ -171,50 +209,68 @@ namespace Tgstation.Server.Client } } - /// - public Task Create(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, null, cancellationToken); + async Task RefreshToken(CancellationToken cancellationToken) + { + if (tokenRefreshHeaders == null) + return false; + + try + { + var token = await RunRequest(Routes.Root, null, HttpMethod.Post, null, true, cancellationToken); + headers = new ApiHeaders(headers.UserAgent!, token.Bearer!); + } + catch (ClientException) + { + return false; + } + + return true; + } /// - public Task Read(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, null, cancellationToken); + public Task Create(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, null, false, cancellationToken); /// - public Task Update(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Post, null, cancellationToken); + public Task Read(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, null, false, cancellationToken); /// - public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, cancellationToken); + public Task Update(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Post, null, false, cancellationToken); /// - public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, cancellationToken); + public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); /// - public Task Create(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, null, cancellationToken); + public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); /// - public Task Delete(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, null, cancellationToken); + public Task Create(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, null, false, cancellationToken); /// - public Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, instanceId, cancellationToken); + public Task Delete(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, null, false, cancellationToken); /// - public Task Read(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, instanceId, cancellationToken); + public Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, instanceId, false, cancellationToken); /// - public Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, instanceId, cancellationToken); + public Task Read(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, instanceId, false, cancellationToken); /// - public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, cancellationToken); + public Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, instanceId, false, cancellationToken); /// - public Task Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Delete, instanceId, cancellationToken); + public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, false, cancellationToken); /// - public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, cancellationToken); + public Task Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Delete, instanceId, false, cancellationToken); /// - public Task Create(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, instanceId, cancellationToken); + public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, false, cancellationToken); /// - public Task Patch(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), new HttpMethod("PATCH"), instanceId, cancellationToken); + public Task Create(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, instanceId, false, cancellationToken); + + /// + public Task Patch(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Patch, instanceId, false, cancellationToken); /// public void AddRequestLogger(IRequestLogger requestLogger) => requestLoggers.Add(requestLogger ?? throw new ArgumentNullException(nameof(requestLogger))); diff --git a/src/Tgstation.Server.Client/ApiClientFactory.cs b/src/Tgstation.Server.Client/ApiClientFactory.cs index 0b686b77c2..d3f137baeb 100644 --- a/src/Tgstation.Server.Client/ApiClientFactory.cs +++ b/src/Tgstation.Server.Client/ApiClientFactory.cs @@ -7,6 +7,6 @@ namespace Tgstation.Server.Client sealed class ApiClientFactory : IApiClientFactory { /// - public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders) => new ApiClient(new HttpClient(), url, apiHeaders); + public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) => new ApiClient(new HttpClient(), url, apiHeaders, tokenRefreshHeaders); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/IApiClientFactory.cs b/src/Tgstation.Server.Client/IApiClientFactory.cs index b8b5f91f96..3cc76b8d1f 100644 --- a/src/Tgstation.Server.Client/IApiClientFactory.cs +++ b/src/Tgstation.Server.Client/IApiClientFactory.cs @@ -13,7 +13,8 @@ namespace Tgstation.Server.Client /// /// The base /// The for the + /// The to use to generate a new . /// A new - IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders); + IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders); } } diff --git a/src/Tgstation.Server.Client/IServerClientFactory.cs b/src/Tgstation.Server.Client/IServerClientFactory.cs index a3de3cb8d6..83c8905f64 100644 --- a/src/Tgstation.Server.Client/IServerClientFactory.cs +++ b/src/Tgstation.Server.Client/IServerClientFactory.cs @@ -19,6 +19,7 @@ namespace Tgstation.Server.Client /// The password for the /// Optional initial s to add to the . /// Optional representing timeout for the connection + /// Attempt to refresh the received when it expires or becomes invalid. and will be stored in memory if this is . /// Optional for the operation /// A resulting in a new Task CreateFromLogin( @@ -27,6 +28,7 @@ namespace Tgstation.Server.Client string password, IEnumerable? requestLoggers = null, TimeSpan? timeout = null, + bool attemptLoginRefresh = true, CancellationToken cancellationToken = default); /// diff --git a/src/Tgstation.Server.Client/ServerClientFactory.cs b/src/Tgstation.Server.Client/ServerClientFactory.cs index 225b437d06..9dc883cca1 100644 --- a/src/Tgstation.Server.Client/ServerClientFactory.cs +++ b/src/Tgstation.Server.Client/ServerClientFactory.cs @@ -38,6 +38,7 @@ namespace Tgstation.Server.Client string password, IEnumerable? requestLoggers = null, TimeSpan? timeout = null, + bool attemptRefreshLogin = true, CancellationToken cancellationToken = default) { if (host == null) @@ -50,7 +51,8 @@ namespace Tgstation.Server.Client requestLoggers ??= Enumerable.Empty(); Token token; - using (var api = ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, username, password))) + var loginHeaders = new ApiHeaders(productHeaderValue, username, password); + using (var api = ApiClientFactory.CreateApiClient(host, loginHeaders, null)) { foreach (var requestLogger in requestLoggers) api.AddRequestLogger(requestLogger); @@ -64,7 +66,9 @@ namespace Tgstation.Server.Client if (timeout.HasValue) client.Timeout = timeout.Value; - return client; + var apiHeaders = new ApiHeaders(productHeaderValue, token.Bearer!); + + return new ServerClient(ApiClientFactory.CreateApiClient(host, apiHeaders, loginHeaders), token); } /// @@ -77,7 +81,7 @@ namespace Tgstation.Server.Client if (token.Bearer == null) throw new ArgumentException("token.Bearer should not be null!", nameof(token)); - return new ServerClient(ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer)), token); + return new ServerClient(ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer), null), token); } } } diff --git a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs index 3266d176fd..29486a7535 100644 --- a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs +++ b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs @@ -39,7 +39,7 @@ namespace Tgstation.Server.Client.Tests var httpClient = new Mock(); httpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(response)); - var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake")); + var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"), null); var result = await client.Read(Routes.Byond, default).ConfigureAwait(false); Assert.AreEqual(sample.Version, result.Version); @@ -64,7 +64,7 @@ namespace Tgstation.Server.Client.Tests var httpClient = new Mock(); httpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(response)); - var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake")); + var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"), null); await Assert.ThrowsExceptionAsync(() => client.Read(Routes.Byond, default)).ConfigureAwait(false); } diff --git a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs index 847a81fb49..8d9d760faa 100644 --- a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs @@ -86,7 +86,7 @@ namespace Tgstation.Server.Host.Setup.Tests var mockFailCommand = new Mock(); mockFailCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny())).Throws(new Exception()).Verifiable(); - void SetDbCommandCreator(Mock mock, Func creator) => mock.Protected().Setup("CreateDbCommand").Returns(creator).Verifiable(); + static void SetDbCommandCreator(Mock mock, Func creator) => mock.Protected().Setup("CreateDbCommand").Returns(creator).Verifiable(); var mockGoodDbConnection = new Mock(); mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); @@ -144,7 +144,6 @@ namespace Tgstation.Server.Host.Setup.Tests "-27", "5000", "fake token", - "y", //logging config "no", //cp config @@ -167,7 +166,6 @@ namespace Tgstation.Server.Host.Setup.Tests String.Empty, String.Empty, "n", - "y", //logging config "y", "not actually verified because lol mocks /../!@#$%^&*()/..///.", @@ -191,7 +189,6 @@ namespace Tgstation.Server.Host.Setup.Tests String.Empty, String.Empty, "y", - "y", "will faile", String.Empty, String.Empty, diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index e32ed5e505..523b101a15 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -144,7 +144,7 @@ namespace Tgstation.Server.Tests { try { - return await clientFactory.CreateFromLogin(server.Url, User.AdminName, User.DefaultAdminPassword).ConfigureAwait(false); + return await clientFactory.CreateFromLogin(server.Url, User.AdminName, User.DefaultAdminPassword, attemptLoginRefresh: false).ConfigureAwait(false); } catch (HttpRequestException) {