Merge pull request #6670 from duplicati/feature/support-api-key-with-filen
Add support for Filen.io API key
This commit is contained in:
@@ -39,6 +39,10 @@ public class FilenBackend : IStreamingBackend
|
||||
/// </summary>
|
||||
private const string MoveToTrashOption = "move-to-trash";
|
||||
/// <summary>
|
||||
/// The API key option name
|
||||
/// </summary>
|
||||
private const string ApiKeyOption = "api-key";
|
||||
/// <summary>
|
||||
/// The Filen client instance
|
||||
/// </summary>
|
||||
private FilenClient? _client;
|
||||
@@ -59,6 +63,10 @@ public class FilenBackend : IStreamingBackend
|
||||
/// </summary>
|
||||
private readonly string? _twoFactorCode;
|
||||
/// <summary>
|
||||
/// The API key, if any
|
||||
/// </summary>
|
||||
private readonly string? _apiKey;
|
||||
/// <summary>
|
||||
/// Whether to move files to the trash instead of deleting them
|
||||
/// </summary>
|
||||
private readonly bool _moveToTrash;
|
||||
@@ -93,6 +101,7 @@ public class FilenBackend : IStreamingBackend
|
||||
|
||||
_moveToTrash = Utility.Utility.ParseBoolOption(options, MoveToTrashOption);
|
||||
_twoFactorCode = options.GetValueOrDefault(TwoFactorOption);
|
||||
_apiKey = options.GetValueOrDefault(ApiKeyOption);
|
||||
_timeout = TimeoutOptionsHelper.Parse(options);
|
||||
}
|
||||
|
||||
@@ -109,7 +118,7 @@ public class FilenBackend : IStreamingBackend
|
||||
_client = null;
|
||||
var httpClient = HttpClientHelper.CreateClient();
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
_client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, cancellationToken).ConfigureAwait(false);
|
||||
_client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, _apiKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _client;
|
||||
@@ -129,10 +138,19 @@ public class FilenBackend : IStreamingBackend
|
||||
public IList<ICommandLineArgument> SupportedCommands => [
|
||||
.. AuthOptionsHelper.GetOptions(),
|
||||
new CommandLineArgument(TwoFactorOption, CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong),
|
||||
new CommandLineArgument(ApiKeyOption, CommandLineArgument.ArgumentType.Password, Strings.FilenBackend.ApiKeyShort, Strings.FilenBackend.ApiKeyLong),
|
||||
new CommandLineArgument(MoveToTrashOption, CommandLineArgument.ArgumentType.Boolean, Strings.FilenBackend.MoveToTrashShort, Strings.FilenBackend.MoveToTrashLong),
|
||||
.. TimeoutOptionsHelper.GetOptions()
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets an API key for the account
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token to use</param>
|
||||
/// <returns>The API key</returns>
|
||||
internal async Task<string?> GetApiKey(CancellationToken cancellationToken)
|
||||
=> (await GetClientAsync(cancellationToken))?.ApiKey;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the folder uuid for the folder this backend is working in
|
||||
/// </summary>
|
||||
|
||||
@@ -182,6 +182,11 @@ public class FilenClient : IDisposable
|
||||
/// </summary>
|
||||
public DateTime ValidUntil => _validUntil;
|
||||
|
||||
/// <summary>
|
||||
/// The API key for the client
|
||||
/// </summary>
|
||||
internal string ApiKey => _authResult.ApiKey;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Filen client and authenticates
|
||||
/// </summary>
|
||||
@@ -189,74 +194,136 @@ public class FilenClient : IDisposable
|
||||
/// <param name="email">The email address to use for login</param>
|
||||
/// <param name="password">The password to use for login</param>
|
||||
/// <param name="twoFactorCode">The two-factor code to use for login</param>
|
||||
/// <param name="apiKey">The API key to use for login</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
|
||||
/// <returns>The authenticated Filen client</returns>
|
||||
public static async Task<FilenClient> CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, CancellationToken cancellationToken)
|
||||
public static async Task<FilenClient> CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, string? apiKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = GatewayUrls[Random.Shared.Next(0, GatewayUrls.Count)];
|
||||
var authResult = await FilenLogin.AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, cancellationToken).ConfigureAwait(false);
|
||||
var authResult = await AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, apiKey, cancellationToken).ConfigureAwait(false);
|
||||
return new FilenClient(httpClient, authResult, baseUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Methods used for the initial login
|
||||
/// Returns the authentication information for the user
|
||||
/// </summary>
|
||||
private static class FilenLogin
|
||||
/// <param name="httpClient">The HTTP client to use for requests</param>
|
||||
/// <param name="baseUrl">The base url for all requests</param>
|
||||
/// <param name="email">The email address to use for login</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
|
||||
/// <returns>The authentication information for the user</returns>
|
||||
private static async Task<AuthInfo> GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the authentication information for the user
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client to use for requests</param>
|
||||
/// <param name="baseUrl">The base url for all requests</param>
|
||||
/// <param name="email">The email address to use for login</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
|
||||
/// <returns>The authentication information for the user</returns>
|
||||
private static async Task<AuthInfo> GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken)
|
||||
var loginUrl = $"{baseUrl}/v3/auth/info";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(new { email }), Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return await ExtractDataFromResponse<AuthInfo>(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the user with the Filen API
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client to use for requests</param>
|
||||
/// <param name="baseUrl">The base url for all requests</param>
|
||||
/// <param name="email">The email address to use for login</param>
|
||||
/// <param name="password">The password to use for login</param>
|
||||
/// <param name="twoFactorCode">The two-factor code to use for login</param>
|
||||
/// <param name="apiKey">The API key to use for login</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
|
||||
/// <returns>The authentication result from the initial login</returns>
|
||||
private static async Task<FilenAuthResult> AuthenticateAsync(
|
||||
HttpClient httpClient,
|
||||
string baseUrl,
|
||||
string email,
|
||||
string password,
|
||||
string? twoFactorCode,
|
||||
string? apiKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Always need authInfo to derive the account master key from password
|
||||
var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion(
|
||||
password, authInfo.AuthVersion, authInfo.Salt);
|
||||
|
||||
// 1) Fast-path: if apiKey is provided, try to use it to fetch master keys first
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
var loginUrl = $"{baseUrl}/v3/auth/info";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(new { email }), Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return await ExtractDataFromResponse<AuthInfo>(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the user with the Filen API
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client to use for requests</param>
|
||||
/// <param name="baseUrl">The base url for all requests</param>
|
||||
/// <param name="email">The email address to use for login</param>
|
||||
/// <param name="password">The password to use for login</param>
|
||||
/// <param name="twoFactorCode">The two-factor code to use for login</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
|
||||
/// <returns>The authentication result from the initial login</returns>
|
||||
public static async Task<FilenAuthResult> AuthenticateAsync(HttpClient httpClient, string baseUrl, string email, string password, string? twoFactorCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(twoFactorCode))
|
||||
twoFactorCode = "XXXXXX";
|
||||
|
||||
var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion(password, authInfo.AuthVersion, authInfo.Salt);
|
||||
var loginUrl = $"{baseUrl}/v3/login";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(new { email, password = rootKeys.Password, twoFactorCode, authVersion = authInfo.AuthVersion }), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var result = await ExtractDataFromResponse<AuthResponse>(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// var mk = await GetAllMasterKeys(masterKey1, result.ApiKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var masterKeys = rootKeys.MasterKey.DecryptMetadata(result.MasterKeys);
|
||||
|
||||
return new FilenAuthResult
|
||||
try
|
||||
{
|
||||
ApiKey = result.ApiKey,
|
||||
AccountMasterKey = rootKeys.MasterKey,
|
||||
MasterKeys = masterKeys.Split('|').Select(DerivedKey.Create).ToList()
|
||||
};
|
||||
var mkUrl = $"{baseUrl}/v3/user/masterKeys";
|
||||
using var mkReq = new HttpRequestMessage(HttpMethod.Post, mkUrl);
|
||||
mkReq.Headers.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
// For retrieval, Filen accepts an empty/placeholder body
|
||||
mkReq.Content = new StringContent(
|
||||
JsonSerializer.Serialize(new { masterKeys = "" }),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var mkResp = await httpClient.SendAsync(mkReq, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
mkResp.EnsureSuccessStatusCode();
|
||||
|
||||
var mkResult = await ExtractDataFromResponse<MasterKeysResponse>(mkResp, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var masterKeysPlain = rootKeys.MasterKey.DecryptMetadata(mkResult.MasterKeys);
|
||||
|
||||
return new FilenAuthResult
|
||||
{
|
||||
ApiKey = apiKey,
|
||||
AccountMasterKey = rootKeys.MasterKey,
|
||||
MasterKeys = masterKeysPlain.Split('|').Select(DerivedKey.Create).ToList()
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Any failure (invalid/expired apiKey, network, schema, decrypt) -> fall back to login
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: login endpoint (requires MFA if enabled)
|
||||
if (string.IsNullOrWhiteSpace(twoFactorCode))
|
||||
twoFactorCode = "XXXXXX";
|
||||
|
||||
var loginUrl = $"{baseUrl}/v3/login";
|
||||
using var loginReq = new HttpRequestMessage(HttpMethod.Post, loginUrl);
|
||||
loginReq.Content = new StringContent(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
email,
|
||||
password = rootKeys.Password,
|
||||
twoFactorCode,
|
||||
authVersion = authInfo.AuthVersion
|
||||
}),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var loginResp = await httpClient.SendAsync(loginReq, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var loginResult = await ExtractDataFromResponse<AuthResponse>(loginResp, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var masterKeys = rootKeys.MasterKey.DecryptMetadata(loginResult.MasterKeys);
|
||||
|
||||
return new FilenAuthResult
|
||||
{
|
||||
ApiKey = loginResult.ApiKey,
|
||||
AccountMasterKey = rootKeys.MasterKey,
|
||||
MasterKeys = masterKeys.Split('|').Select(DerivedKey.Create).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class MasterKeysResponse
|
||||
{
|
||||
public string MasterKeys { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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 Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Library.Utility.Options;
|
||||
|
||||
namespace Duplicati.Library.Backend.Filen;
|
||||
|
||||
/// <summary>
|
||||
/// Web module to help users obtain an API key for Filen.io
|
||||
/// </summary>
|
||||
public class GetApiKeyModule : IWebModule
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string Key => "filen-get-api-key";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string DisplayName => "Get Filen.io API Key";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => "Module to help users obtain an API key for Filen.io using their email password, and MFA code.";
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for metadata loading
|
||||
/// </summary>
|
||||
public GetApiKeyModule()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IList<ICommandLineArgument> SupportedCommands =>
|
||||
[
|
||||
.. AuthOptionsHelper.GetOptions(),
|
||||
new CommandLineArgument("two-factor", CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong)
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDictionary<string, string> Execute(IDictionary<string, string?> options)
|
||||
{
|
||||
options.TryGetValue("filen-operation", out var operation);
|
||||
if (operation != "GetApiKey")
|
||||
throw new UserInformationException("Invalid operation", "InvalidOperation");
|
||||
|
||||
options.TryGetValue("url", out var url);
|
||||
if (string.IsNullOrEmpty(url))
|
||||
throw new UserInformationException("URL is required", "UrlOptionMissing");
|
||||
|
||||
var uri = new Utility.Uri(url);
|
||||
|
||||
var newOpts = new Dictionary<string, string?>(options);
|
||||
foreach (var key in uri.QueryParameters.AllKeys)
|
||||
if (key != null)
|
||||
newOpts[key] = uri.QueryParameters[key];
|
||||
|
||||
var backend = new FilenBackend(url, newOpts);
|
||||
var apiKey = backend.GetApiKey(CancellationToken.None).Await();
|
||||
return new Dictionary<string, string> { { "api-key", apiKey ?? string.Empty } };
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDictionary<string, IDictionary<string, string>> GetLookups()
|
||||
=> new Dictionary<string, IDictionary<string, string>>();
|
||||
}
|
||||
@@ -27,6 +27,8 @@ namespace Duplicati.Library.Backend.Strings
|
||||
public static string DisplayName => LC.L(@"Filen.io");
|
||||
public static string TwoFactorShort => LC.L(@"Optional 2-factor code");
|
||||
public static string TwoFactorLong => LC.L(@"The 2-factor code to use for authentication, leave empty if the account is not MFA protected. Note that a new code must be provided by the user for each authentication attempt.");
|
||||
public static string ApiKeyShort => LC.L(@"Optional API key");
|
||||
public static string ApiKeyLong => LC.L(@"The API key to use for authentication, which will work even if the account is MFA protected. Obtain the API key via the Filen CLI tool.");
|
||||
public static string MoveToTrashShort => LC.L(@"Move to trash");
|
||||
public static string MoveToTrashLong => LC.L(@"If set, files will be moved to the trash instead of being deleted permanently.");
|
||||
}
|
||||
|
||||
@@ -37,5 +37,6 @@ public static class WebModules
|
||||
new KeyGenerator(),
|
||||
new KeyUploader(),
|
||||
new Storj.StorjConfig(),
|
||||
new Filen.GetApiKeyModule(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -41,6 +41,19 @@ public record WebModules : IEndpointV1
|
||||
private static IEnumerable<IWebModule> ExecuteGet()
|
||||
=> Library.DynamicLoader.WebLoader.Modules;
|
||||
|
||||
private static string UnmaskUrl(Connection connection, string maskedurl, string? backupId)
|
||||
{
|
||||
var previousUrl = !string.IsNullOrWhiteSpace(backupId) ? connection.GetBackup(backupId)?.TargetURL : null;
|
||||
var unmasked = string.IsNullOrWhiteSpace(previousUrl)
|
||||
? maskedurl
|
||||
: QuerystringMasking.Unmask(maskedurl, previousUrl);
|
||||
|
||||
if (Connection.UrlContainsPasswordPlaceholder(unmasked))
|
||||
throw new ArgumentException("Unmasked URL contains password placeholder");
|
||||
|
||||
return unmasked;
|
||||
}
|
||||
|
||||
private static async Task<Dto.WebModuleOutputDto> ExecutePost(Connection connection, IApplicationSettings applicationSettings, string modulekey, Dictionary<string, string> inputOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
var m = Library.DynamicLoader.WebLoader.Modules.FirstOrDefault(x => x.Key.Equals(modulekey, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -52,6 +65,12 @@ public record WebModules : IEndpointV1
|
||||
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], options, Library.Utility.TempFolder.SystemTempPath, applicationSettings.SecretProvider, cancellationToken);
|
||||
|
||||
if (options.TryGetValue("url", out var maskedurl) && !string.IsNullOrEmpty(maskedurl))
|
||||
{
|
||||
var unmasked = UnmaskUrl(connection, maskedurl, options.GetValueOrDefault("backup-id"));
|
||||
options["url"] = unmasked;
|
||||
}
|
||||
|
||||
return new Dto.WebModuleOutputDto(
|
||||
Status: "OK",
|
||||
Result: m.Execute(options)
|
||||
|
||||
Reference in New Issue
Block a user