From 7d4f9ffab389cefc7218bfa6f63865628ecedcf4 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 12 Dec 2024 08:59:59 +0100 Subject: [PATCH] Implemented a forever-token feature --- .../ServerUtil/Commands/IssueForeverToken.cs | 19 +++++++++++++++++++ .../CommandLine/ServerUtil/Connection.cs | 13 +++++++++++++ Duplicati/CommandLine/ServerUtil/Program.cs | 1 + .../RestAPI/Database/ServerSettings.cs | 4 ++++ Duplicati/Library/RestAPI/Strings.cs | 1 + Duplicati/Server/Program.cs | 4 ++++ Duplicati/Server/WebServerLoader.cs | 5 +++++ .../Abstractions/IJWTTokenProvider.cs | 7 +++++++ Duplicati/WebserverCore/Endpoints/V1/Auth.cs | 8 ++++++++ .../WebserverCore/Middlewares/JWTProvider.cs | 8 ++++++++ 10 files changed, 70 insertions(+) create mode 100644 Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs diff --git a/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs b/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs new file mode 100644 index 000000000..1afcab56d --- /dev/null +++ b/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs @@ -0,0 +1,19 @@ +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; + +namespace Duplicati.CommandLine.ServerUtil.Commands; + +public static class IssueForeverToken +{ + public static Command Create() => + new Command("issue-forever-token", "Issues a long-lived access token") + { + } + .WithHandler(CommandHandler.Create(async (settings) => + { + var token = await (await settings.GetConnection()).CreateForeverToken(); + Console.WriteLine("Token issued:"); + Console.WriteLine(token); + Console.WriteLine(); + })); +} diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs index 36213914e..9c8cbbbb0 100644 --- a/Duplicati/CommandLine/ServerUtil/Connection.cs +++ b/Duplicati/CommandLine/ServerUtil/Connection.cs @@ -453,6 +453,19 @@ public class Connection return await response.Content.ReadAsStreamAsync(); } + /// + /// Creates a forever token + /// + /// The token + public async Task CreateForeverToken() + { + var response = await client.PostAsync($"auth/issue-forever-token", null); + await EnsureSuccessStatusCodeWithParsing(response); + + return await response.Content.ReadAsStringAsync(); + } + + /// /// The server error structure for JSON deserialization /// diff --git a/Duplicati/CommandLine/ServerUtil/Program.cs b/Duplicati/CommandLine/ServerUtil/Program.cs index 1e17bb8ab..1b907b94b 100644 --- a/Duplicati/CommandLine/ServerUtil/Program.cs +++ b/Duplicati/CommandLine/ServerUtil/Program.cs @@ -31,6 +31,7 @@ public static class Program Import.Create(), Export.Create(), Health.Create(), + IssueForeverToken.Create(), }; rootCmd = SettingsBinder.AddGlobalOptions(rootCmd); diff --git a/Duplicati/Library/RestAPI/Database/ServerSettings.cs b/Duplicati/Library/RestAPI/Database/ServerSettings.cs index 2bf40fab9..ec66c934e 100644 --- a/Duplicati/Library/RestAPI/Database/ServerSettings.cs +++ b/Duplicati/Library/RestAPI/Database/ServerSettings.cs @@ -493,6 +493,10 @@ namespace Duplicati.Server.Database } } + // Enabling forever-tokens is not persisted in the database, + // as it is meant to be a temporary setting + public bool EnableForeverTokens { get; set; } = false; + public string RemoteControlConfig { get => settings[CONST.REMOTE_CONTROL_CONFIG]; diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index 85a81287e..9cb3c5e6e 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -48,6 +48,7 @@ Error message: {0}", error); } public static string TempdirLong { get { return LC.L(@"Use this option to supply an alternative folder for temporary storage. By default the system default temporary folder is used. Note that also SQLite will put temporary files in this temporary folder."); } } public static string TempdirShort { get { return LC.L(@"Temporary storage folder"); } } public static string WebserverResetJwtConfigDescription { get { return LC.L(@"Reset the JWT configuration, invalidating any issued login tokens"); } } + public static string WebserverEnableForeverTokenDescription { get { return LC.L(@"Enable the use of long-lived access tokens"); } } public static string WebserverDisableVisualCaptchaDescription { get { return LC.L(@"Disable the visual captcha"); } } public static string WebserverApiOnlyDescription { get { return LC.L(@"Disable the web interface and only allow API access"); } } public static string WebserverDisableSigninTokensDescription { get { return LC.L(@"Disable the use of signin tokens"); } } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index b2fda6133..d923329d3 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -533,6 +533,9 @@ namespace Duplicati.Server DataConnection.ExecuteWithCommand((con) => con.ExecuteNonQuery("DELETE FROM TokenFamily")); } + if (Library.Utility.Utility.ParseBoolOption(commandlineOptions, WebServerLoader.OPTION_WEBSERVICE_ENABLE_FOREVER_TOKEN)) + DataConnection.ApplicationSettings.EnableForeverTokens = true; + if (commandlineOptions.ContainsKey(WebServerLoader.OPTION_WEBSERVICE_DISABLE_VISUAL_CAPTCHA)) DataConnection.ApplicationSettings.DisableVisualCaptcha = Library.Utility.Utility.ParseBool(commandlineOptions[WebServerLoader.OPTION_WEBSERVICE_DISABLE_VISUAL_CAPTCHA], true); @@ -947,6 +950,7 @@ namespace Duplicati.Server new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_PASSWORD, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Password, Strings.Program.WebserverPasswordDescription, Strings.Program.WebserverPasswordDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_ALLOWEDHOSTNAMES, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverAllowedhostnamesDescription, Strings.Program.WebserverAllowedhostnamesDescription, null, [WebServerLoader.OPTION_WEBSERVICE_ALLOWEDHOSTNAMES_ALT]), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_RESET_JWT_CONFIG, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverResetJwtConfigDescription, Strings.Program.WebserverResetJwtConfigDescription), + new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_ENABLE_FOREVER_TOKEN, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverEnableForeverTokenDescription, Strings.Program.WebserverEnableForeverTokenDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLE_VISUAL_CAPTCHA, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverDisableVisualCaptchaDescription, Strings.Program.WebserverDisableVisualCaptchaDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_API_ONLY, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverApiOnlyDescription, Strings.Program.WebserverApiOnlyDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLE_SIGNIN_TOKENS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverDisableSigninTokensDescription, Strings.Program.WebserverDisableSigninTokensDescription), diff --git a/Duplicati/Server/WebServerLoader.cs b/Duplicati/Server/WebServerLoader.cs index f798ab5df..3a399eb19 100644 --- a/Duplicati/Server/WebServerLoader.cs +++ b/Duplicati/Server/WebServerLoader.cs @@ -65,6 +65,11 @@ public static class WebServerLoader /// public const string OPTION_WEBSERVICE_RESET_JWT_CONFIG = "webservice-reset-jwt-config"; + /// + /// Option for enabling the forever token + /// + public const string OPTION_WEBSERVICE_ENABLE_FOREVER_TOKEN = "webservice-enable-forver-token"; + /// /// Option for disabling the visual captcha /// diff --git a/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs b/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs index 1c513fefe..96a7942b0 100644 --- a/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs +++ b/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs @@ -57,13 +57,20 @@ public interface IJWTTokenProvider /// The user ID the token is for. /// The token family ID the token is for. /// The expiration time of the token, can only be shorter than the current. + /// The JWT token. string CreateAccessToken(string userId, string tokenFamilyId, TimeSpan? expiration = null); /// + /// Creates a JWT token that can be used to access resources "forever". + /// + /// The JWT token. + string CreateForeverToken(); + /// /// Creates a JWT token that can be used to refresh an access token. /// /// The user ID the token is for. /// The token family ID the token is for. /// The counter of the token family the token is for. + /// The JWT token. string CreateRefreshToken(string userId, string tokenFamilyId, int counter); /// diff --git a/Duplicati/WebserverCore/Endpoints/V1/Auth.cs b/Duplicati/WebserverCore/Endpoints/V1/Auth.cs index 4234dffed..049f8aea8 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/Auth.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/Auth.cs @@ -134,6 +134,14 @@ public partial class Auth : IEndpointV1 var singleOperationToken = tokenProvider.CreateSingleOperationToken("web-api", operation); return new Dto.SingleOperationTokenOutputDto(singleOperationToken); }).RequireAuthorization(); + + group.MapPost("auth/issue-forever-token", ([FromServices] Connection connection, [FromServices] IJWTTokenProvider tokenProvider) => + { + if (!connection.ApplicationSettings.EnableForeverTokens) + throw new UnauthorizedException("Forever tokens are not enabled"); + + return new Dto.AccessTokenOutput(tokenProvider.CreateForeverToken()); + }).RequireAuthorization(); } private static void AddCookie(HttpContext context, string name, string value, DateTimeOffset expires) diff --git a/Duplicati/WebserverCore/Middlewares/JWTProvider.cs b/Duplicati/WebserverCore/Middlewares/JWTProvider.cs index 470aadd95..c0bddafec 100644 --- a/Duplicati/WebserverCore/Middlewares/JWTProvider.cs +++ b/Duplicati/WebserverCore/Middlewares/JWTProvider.cs @@ -32,6 +32,7 @@ public record JWTConfig public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider { private const string TemporaryFamilyId = "temporary"; + private const string ForeverTokenUserId = "forever-token"; public string CreateSingleOperationToken(string userId, string operation) => GenerateToken([ new Claim(Claims.Type, TokenType.SingleOperationToken.ToString()), @@ -52,6 +53,13 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider new Claim(Claims.Family, tokenFamilyId) ], DateTime.Now, expires: DateTime.Now.AddMinutes(Math.Min(jWTConfig.AccessTokenDurationInMinutes, expiration?.TotalMinutes ?? jWTConfig.AccessTokenDurationInMinutes))); + public string CreateForeverToken() + => GenerateToken([ + new Claim(Claims.Type, TokenType.AccessToken.ToString()), + new Claim(Claims.UserId, ForeverTokenUserId), + new Claim(Claims.Family, TemporaryFamilyId) + ], DateTime.Now, expires: DateTime.Now.AddYears(10)); + public string CreateRefreshToken(string userId, string tokenFamilyId, int counter) => GenerateToken([ new Claim(Claims.Type, TokenType.RefreshToken.ToString()),