duplicati/Duplicati/Library/Backend/Backblaze/B2AuthHelper.cs
Kenneth Skovhede 89612753af Updates to fic nullability issues with B2.
Also fixed a few places where blocking calls were made.
2025-03-24 17:08:36 +01:00

216 lines
No EOL
9.3 KiB
C#

// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Net;
using System.Text;
using Duplicati.Library.Backend.Backblaze.Model;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility.Options;
using Newtonsoft.Json;
using Exception = System.Exception;
namespace Duplicati.Library.Backend.Backblaze;
/// <summary>
/// Helper class for handling Backblaze B2 authorization
/// </summary>
/// <param name="userid">Username</param>
/// <param name="password">Password</param>
/// <param name="httpClient">HttpClient instance to use and pass along to JsonWebHelperHttpClient</param>
public class B2AuthHelper(string userid, string password, HttpClient httpClient, TimeoutOptionsHelper.Timeouts timeouts) : JsonWebHelperHttpClient(httpClient)
{
/// <summary>
/// The configuration details after authorization
/// </summary>
/// <param name="AccountID">The account ID</param>
/// <param name="APIUrl">The API URL</param>
/// <param name="AuthorizationToken">The authorization token</param>
/// <param name="DownloadUrl">The download URL</param>
public record AuthResponse(string AccountID, string APIUrl, string AuthorizationToken, string DownloadUrl);
/// <summary>
/// Cached authorization response
/// </summary>
private AuthResponse? _CachedAuthResponse;
/// <summary>
/// Cached authorization response expiration time
/// </summary>
private DateTime _configExpires;
/// <summary>
/// Defines lifetime of cached authorization response
/// </summary>
private const int CACHE_EXPIRATION_MINUTES = 60;
/// <summary>
/// Authorization URL
/// </summary>
internal const string AUTH_URL = "https://api.backblazeb2.com/b2api/v1/b2_authorize_account";
/// <summary>
/// Maximum number of retries for authorization
/// </summary>
private const int MAX_AUTHORIZATION_RETRIES = 5;
/// <summary>
/// Creates an HTTP request message with authorization header
/// </summary>
/// <param name="url">The URL for the request</param>
/// <param name="method">HTTP method (defaults to GET if null)</param>
/// <returns>Configured HttpRequestMessage</returns>
public override async Task<HttpRequestMessage> CreateRequestAsync(string url, HttpMethod method, CancellationToken cancellationToken)
{
var request = await base.CreateRequestAsync(url, method, cancellationToken).ConfigureAwait(false);
var config = await GetConfigAsync(cancellationToken).ConfigureAwait(false);
request.Headers.TryAddWithoutValidation("Authorization", config.AuthorizationToken);
request.Headers.Add("User-Agent", UserAgent);
return request;
}
/// <summary>
/// Cleans the url to remove trailing slashes
/// </summary>
/// <param name="url">URL</param>
private static string DropTrailingSlashes(string url)
{
if (string.IsNullOrWhiteSpace(url))
return url;
while (url.EndsWith("/", StringComparison.Ordinal))
url = url.Substring(0, url.Length - 1);
return url;
}
public async Task<AuthResponse> GetConfigAsync(CancellationToken cancelToken)
{
if (_CachedAuthResponse != null && _configExpires >= DateTime.UtcNow)
return _CachedAuthResponse;
var retries = 0;
while (true)
{
HttpResponseMessage? response = null;
try
{
using var request = await base.CreateRequestAsync(AUTH_URL, HttpMethod.Get, cancelToken).ConfigureAwait(false);
request.Headers.Add("Authorization", $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(userid + ":" + password))}");
request.Headers.Add("ContentType", "application/json; charset=utf-8");
var authResponse = await Utility.Utility.WithTimeout(timeouts.ShortTimeout, cancelToken, async ct =>
{
response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await ReadJsonResponseAsync<ApiAuthResponse>(response, ct).ConfigureAwait(false)
?? throw new Exception("Failed to parse authorization response");
}).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(authResponse.AccountID))
throw new Exception("Account ID is missing from authorization response");
if (string.IsNullOrWhiteSpace(authResponse.APIUrl))
throw new Exception("API URL is missing from authorization response");
if (string.IsNullOrWhiteSpace(authResponse.AuthorizationToken))
throw new Exception("Authorization token is missing from authorization response");
if (string.IsNullOrWhiteSpace(authResponse.DownloadUrl))
throw new Exception("Download URL is missing from authorization response");
authResponse.APIUrl = DropTrailingSlashes(authResponse.APIUrl);
authResponse.DownloadUrl = DropTrailingSlashes(authResponse.DownloadUrl);
_configExpires = DateTime.UtcNow + TimeSpan.FromMinutes(CACHE_EXPIRATION_MINUTES);
return _CachedAuthResponse = new AuthResponse(authResponse.AccountID, authResponse.APIUrl, authResponse.AuthorizationToken, authResponse.DownloadUrl);
}
catch (Exception ex)
{
var clientError = false;
try
{
// Only retry once on client errors
if (ex is HttpRequestException { StatusCode: not null } exception)
{
var sc = (int)exception.StatusCode;
clientError = sc is >= 400 and <= 499;
}
}
catch
{
// ignored
}
if (retries >= (clientError ? 1 : MAX_AUTHORIZATION_RETRIES))
{
await AttemptParseAndThrowExceptionAsync(ex, response, cancelToken);
throw;
}
Thread.Sleep(TimeSpan.FromSeconds(Math.Pow(2, retries)));
retries++;
}
finally
{
response?.Dispose();
}
}
}
/// <summary>
/// Parses the exception and throws a new exception with the error message
/// </summary>
/// <param name="ex">Exception to be parsed</param>
/// <param name="responseContext">Response context</param>
/// <exception cref="Exception">New detailed exception</exception>
public override async Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage? responseContext, CancellationToken cancelToken)
{
if (ex is not HttpRequestException || responseContext == null)
return;
if (ex is HttpRequestException && responseContext.StatusCode == HttpStatusCode.TooManyRequests)
throw new TooManyRequestException(responseContext.Headers.RetryAfter);
var rawData = await Utility.Utility.WithTimeout(timeouts.ShortTimeout, cancelToken, ct =>
{
using var stream = responseContext.Content.ReadAsStream();
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}).ConfigureAwait(false);
var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(rawData)
?? new ErrorResponse { Status = (int)responseContext.StatusCode, Message = responseContext.ReasonPhrase };
throw new UserInformationException($"Backblaze ErrorResponse: {errorResponse.Status} - {errorResponse.Code}: {errorResponse.Message}", "BackblazeErrorResponse");
}
/// <summary>
/// Extract Http status code from exception
/// </summary>
/// <param name="ex">Exception to be parsed</param>
public static HttpStatusCode GetExceptionStatusCode(Exception ex)
{
return ex is HttpRequestException { StatusCode: not null } httpEx ? httpEx.StatusCode.Value : default;
}
}