diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs index 39346d029..720a1d94f 100644 --- a/Duplicati/CommandLine/ServerUtil/Connection.cs +++ b/Duplicati/CommandLine/ServerUtil/Connection.cs @@ -174,13 +174,13 @@ public class Connection { if (!string.IsNullOrWhiteSpace(settings.RefreshToken)) { - var (accessToken, refreshToken) = await LoginWithRefreshToken(client, settings.RefreshToken); + var (accessToken, refreshToken, refreshNonce) = await LoginWithRefreshToken(client, settings.RefreshToken, settings.RefreshNonce); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to get access token"); if (string.IsNullOrWhiteSpace(refreshToken)) throw new InvalidOperationException("Failed to get refresh token"); - (settings with { RefreshToken = refreshToken }).Save(console); + (settings with { RefreshToken = refreshToken, RefreshNonce = refreshNonce }).Save(console); return CreateConnectionWithClient(client, accessToken); } } @@ -223,12 +223,12 @@ public class Connection ).CreateSigninToken("server-cli"); var responseTask = client.PostAsync("auth/signin", JsonContent.Create(new { SigninToken = signinjwt, RememberMe = obtainRefreshToken })); - var (accessToken, refreshToken) = await ParseAuthResponse(responseTask); + var (accessToken, refreshToken, refreshNonce) = await ParseAuthResponse(responseTask); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to get access token"); if (!string.IsNullOrWhiteSpace(refreshToken)) - (settings with { RefreshToken = refreshToken }).Save(console); + (settings with { RefreshToken = refreshToken, RefreshNonce = refreshNonce }).Save(console); return CreateConnectionWithClient(client, accessToken); } @@ -260,12 +260,12 @@ public class Connection if (string.IsNullOrWhiteSpace(settings.Password)) throw new UserReportedException("Password is required"); - var (accessToken, refreshToken) = await LoginWithPassword(client, settings.Password, obtainRefreshToken); + var (accessToken, refreshToken, refreshNonce) = await LoginWithPassword(client, settings.Password, obtainRefreshToken); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to get access token"); if (!string.IsNullOrWhiteSpace(refreshToken)) - (settings with { RefreshToken = refreshToken }).Save(console); + (settings with { RefreshToken = refreshToken, RefreshNonce = refreshNonce }).Save(console); return CreateConnectionWithClient(client, accessToken); } @@ -295,7 +295,7 @@ public class Connection /// The password to use /// Whether to obtain a refresh token /// The access and refresh tokens - private static Task<(string AccessToken, string? RefreshToken)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken) + private static Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken) => ParseAuthResponse( client.PostAsync("auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken })) ); @@ -305,12 +305,15 @@ public class Connection /// /// The HTTP client /// The refresh token to use - /// The access and refresh tokens - private static Task<(string AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken) - => ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh") + /// The access token, refresh tokens, and nonce + private static Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> LoginWithRefreshToken(HttpClient client, string refreshToken, string? nonce) + { + return ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh") { - Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } } + Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } }, + Content = string.IsNullOrWhiteSpace(nonce) ? null : JsonContent.Create(new { Nonce = nonce }) })); + } /// @@ -318,7 +321,7 @@ public class Connection /// /// The response to parse /// The access and refresh tokens - private static async Task<(string AccessToken, string? RefreshToken)> ParseAuthResponse(Task responseTask) + private static async Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> ParseAuthResponse(Task responseTask) { var response = await responseTask; await EnsureSuccessStatusCodeWithParsing(response); @@ -328,10 +331,12 @@ public class Connection if (!json.TryGetValue("AccessToken", out var accessToken)) throw new InvalidOperationException("Failed to get access token"); + json.TryGetValue("RefreshNonce", out var refreshNonce); + response.Headers.TryGetValues("Set-Cookie", out var cookies); var refreshToken = cookies?.SelectMany(c => c.Split(';')).FirstOrDefault(c => c.StartsWith("RefreshToken_"))?.Split('=', 2)[1]; - return (accessToken, refreshToken); + return (accessToken, refreshToken, refreshNonce); } /// @@ -561,7 +566,7 @@ public class Connection /// The token public async Task CreateForeverToken() { - var (accessToken, _) = await ParseAuthResponse(client.PostAsync("auth/issue-forever-token", null)); + var (accessToken, _, _) = await ParseAuthResponse(client.PostAsync("auth/issue-forever-token", null)); return accessToken; } diff --git a/Duplicati/CommandLine/ServerUtil/Settings.cs b/Duplicati/CommandLine/ServerUtil/Settings.cs index 7dffb5a8d..aa476e2e7 100644 --- a/Duplicati/CommandLine/ServerUtil/Settings.cs +++ b/Duplicati/CommandLine/ServerUtil/Settings.cs @@ -36,6 +36,7 @@ namespace Duplicati.CommandLine.ServerUtil; /// /// The commandline password /// The saved refresh token +/// The saved refresh nonce /// The host url to connect to /// The settings file where data is loaded/saved /// Whether to disable TLS/SSL certificate trust check @@ -46,6 +47,7 @@ namespace Duplicati.CommandLine.ServerUtil; public sealed record Settings( string? Password, string? RefreshToken, + string? RefreshNonce, Uri HostUrl, string SettingsFile, bool Insecure, @@ -63,6 +65,7 @@ public sealed record Settings( /// The server datafolder, if any private sealed record PersistedSettings( string? RefreshToken, + string? RefreshNonce, Uri HostUrl, string? ServerDatafolder ); @@ -127,6 +130,7 @@ public sealed record Settings( return new Settings( password, persistedSettings?.RefreshToken, + persistedSettings?.RefreshNonce, hostUrl, settingsFile, insecure, @@ -179,7 +183,7 @@ public sealed record Settings( File.WriteAllText(SettingsFile, JsonSerializer.Serialize(LoadSettings(SettingsFile, thisKey) .Where(x => x.HostUrl != HostUrl) - .Append(new PersistedSettings(RefreshToken, HostUrl, DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet))) + .Append(new PersistedSettings(RefreshToken, RefreshNonce, HostUrl, DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet))) .Select(x => x with { RefreshToken = string.IsNullOrWhiteSpace(x.RefreshToken) || thisKey == null @@ -197,7 +201,7 @@ public sealed record Settings( { return Connection.Connect(this); } - + /// /// Gets a connection to the server /// diff --git a/Duplicati/Library/RestAPI/Database/PbkdfConfig.cs b/Duplicati/Library/RestAPI/Database/PbkdfConfig.cs new file mode 100644 index 000000000..3f924411f --- /dev/null +++ b/Duplicati/Library/RestAPI/Database/PbkdfConfig.cs @@ -0,0 +1,111 @@ +// 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; +using System.Security.Cryptography; + +#nullable enable + +namespace Duplicati.Server; + +/// +/// This class is used to store the PBKDF configuration parameters +/// +public record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash) +{ + /// + /// The version to embed in the configuration + /// + private const int _Version = 1; + /// + /// The algorithm to use + /// + private const string _Algorithm = "PBKDF2"; + /// + /// The hash algorithm to use + /// + private const string _HashAlorithm = "SHA256"; + /// + /// The number of iterations to use + /// + private const int _Iterations = 10000; + /// + /// The size of the hash + /// + private const int _HashSize = 32; + + /// + /// Creates a default PBKDF2 configuration + /// + public static PbkdfConfig Default => new PbkdfConfig(_Algorithm, _Version, string.Empty, _Iterations, _HashAlorithm, string.Empty); + + /// + /// Creates a new PBKDF2 configuration with a random salt + /// + /// The password to hash + public static PbkdfConfig CreatePBKDF2(string password) + { + var prng = RandomNumberGenerator.Create(); + var buf = new byte[_HashSize]; + prng.GetBytes(buf); + + var salt = Convert.ToBase64String(buf); + var pbkdf2 = new Rfc2898DeriveBytes(password, buf, _Iterations, new HashAlgorithmName(_HashAlorithm)); + var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize)); + + return new PbkdfConfig(_Algorithm, _Version, salt, _Iterations, _HashAlorithm, pwd); + } + + /// + /// Calculates the hash for the given password using the current configuration + /// + /// The password to hash + /// The hashed password + private string ComputeHash(string password) + { + var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm)); + return Convert.ToBase64String(pbkdf2.GetBytes(_HashSize)); + } + + /// + /// Creates a new PBKDF2 configuration with the given password + /// + /// The password to use + /// The updated PBKDF2 configuration + public PbkdfConfig WithPassword(string password) + => WithHash(ComputeHash(password)); + + /// + /// Creates a new PBKDF2 configuration with the given hash + /// + /// The hash to use + /// The updated PBKDF2 configuration + public PbkdfConfig WithHash(string hash) + => this with { Hash = hash }; + + /// + /// Verifies a password against a PBKDF2 configuration + /// + /// The password to verify + /// True if the password matches the configuration + public bool VerifyPassword(string password) + => CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(Hash), Convert.FromBase64String(ComputeHash(password))); +} diff --git a/Duplicati/Library/RestAPI/Database/ServerSettings.cs b/Duplicati/Library/RestAPI/Database/ServerSettings.cs index db882592c..7daf18ea5 100644 --- a/Duplicati/Library/RestAPI/Database/ServerSettings.cs +++ b/Duplicati/Library/RestAPI/Database/ServerSettings.cs @@ -330,63 +330,6 @@ namespace Duplicati.Server.Database } } - /// - /// This class is used to store the PBKDF configuration parameters - /// - private record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash) - { - /// - /// The version to embed in the configuration - /// - private const int _Version = 1; - /// - /// The algorithm to use - /// - private const string _Algorithm = "PBKDF2"; - /// - /// The hash algorithm to use - /// - private const string _HashAlorithm = "SHA256"; - /// - /// The number of iterations to use - /// - private const int _Iterations = 10000; - /// - /// The size of the hash - /// - private const int _HashSize = 32; - - /// - /// Creates a new PBKDF2 configuration with a random salt - /// - /// The password to hash - public static PbkdfConfig CreatePBKDF2(string password) - { - var prng = RandomNumberGenerator.Create(); - var buf = new byte[_HashSize]; - prng.GetBytes(buf); - - var salt = Convert.ToBase64String(buf); - var pbkdf2 = new Rfc2898DeriveBytes(password, buf, _Iterations, new HashAlgorithmName(_HashAlorithm)); - var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize)); - - return new PbkdfConfig(_Algorithm, _Version, salt, _Iterations, _HashAlorithm, pwd); - } - - /// - /// Verifies a password against a PBKDF2 configuration - /// - /// The password to verify - /// True if the password matches the configuration - public bool VerifyPassword(string password) - { - var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm)); - var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize)); - - return pwd == Hash; - } - } - /// /// Verifies a password against the stored PBKDF configuration /// diff --git a/Duplicati/Server/webroot/login/login.js b/Duplicati/Server/webroot/login/login.js index d9c5dad97..2a924cb9a 100644 --- a/Duplicati/Server/webroot/login/login.js +++ b/Duplicati/Server/webroot/login/login.js @@ -15,6 +15,8 @@ $(document).ready(function() { data: JSON.stringify({'Password': $('#login-password').val(), 'RememberMe': true }) }) .done(function(data) { + if (data.RefreshNonce) + localStorage.setItem('v1:persist:duplicati:refreshNonce', data.RefreshNonce); window.location = './'; }) .fail(function(data) { diff --git a/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js b/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js index a9e18fb2a..aef60290f 100644 --- a/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js +++ b/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js @@ -36,10 +36,14 @@ backupApp.controller('AppController', function($rootScope, $scope, $cookies, $lo $scope.isLoggedIn = false; $scope.log_out = function() { + const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce'); + const body = storedNonce ? { Nonce: storedNonce } : undefined; + // Use a path under /auth/refresh to allow the cookie to be sent for deletion // Calling `/auth/logout` also works, but does not revoke the token in the database - AppService.post('/auth/refresh/logout').then(function() { + AppService.post('/auth/refresh/logout', body).then(function() { AppService.clearAccessToken(); + localStorage.removeItem('v1:persist:duplicati:refreshNonce'); location.href = '/login.html'; }, AppUtils.connectionError); }; diff --git a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js index 447baf43d..88cc8ff4f 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js @@ -99,10 +99,16 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS } else { var deferred = $q.defer(); self.access_token_promise = deferred.promise; - $http.post(self.apiurl + '/auth/refresh') + + const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce'); + const body = storedNonce ? { Nonce: storedNonce } : undefined; + + $http.post(self.apiurl + '/auth/refresh', body) .then(function (response) { self.access_token = response.data.AccessToken; self.access_token_promise = null; + if (response.data.RefreshNonce) + localStorage.setItem('v1:persist:duplicati:refreshNonce', response.data.RefreshNonce); deferred.resolve(self.access_token); }, function (response) { // Failed to get a new token, clear the old one diff --git a/Duplicati/Server/webroot/signin/signin.js b/Duplicati/Server/webroot/signin/signin.js index 9ee50ba93..f7805cbf3 100644 --- a/Duplicati/Server/webroot/signin/signin.js +++ b/Duplicati/Server/webroot/signin/signin.js @@ -12,12 +12,18 @@ $(document).ready(function() { return; processing = true; + const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce'); + const body = storedNonce ? { Nonce: storedNonce } : undefined; $.ajax({ url: './api/v1/auth/refresh', - type: 'POST' + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(body) }) .done(function(data) { + if (data.RefreshNonce) + localStorage.setItem('v1:persist:duplicati:refreshNonce', data.RefreshNonce); window.location = './'; }) .fail(function(data) { @@ -54,6 +60,8 @@ $(document).ready(function() { data: JSON.stringify({'SigninToken': $('#signin-token').val(), 'RememberMe': true }) }) .done(function(data) { + if (data.RefreshNonce) + localStorage.setItem('v1:persist:duplicati:refreshNonce', data.RefreshNonce); window.location = './'; }) .fail(function(data) { diff --git a/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs b/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs index 972088096..f8d650cc4 100644 --- a/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs +++ b/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs @@ -90,8 +90,9 @@ public interface IJWTTokenProvider /// 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); + /// Whether the token should be short-lived. + /// The JWT token and the nonce. + (string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived); /// /// Reads a JWT token that only works for a single operation. @@ -117,8 +118,9 @@ public interface IJWTTokenProvider /// Reads a JWT token that can be used to refresh an access token. /// /// The JWT token. + /// The nonce used to validate the token. /// The parsed and validated refresh token. - RefreshToken ReadRefreshToken(string token); + RefreshToken ReadRefreshToken(string token, string? nonce); /// /// Gets the family ID from a JWT token with no family counter. diff --git a/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs b/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs index d68f90a7a..22b239d56 100644 --- a/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs +++ b/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs @@ -29,10 +29,10 @@ public interface ILoginProvider /// Performs a login with a signin token. /// /// The signin token. - /// Whether to issue a refresh token. + /// Whether to issue a short-lived refresh token. /// The cancellation token. - /// The access and refresh tokens. - Task<(string AccessToken, string? RefreshToken)> PerformLoginWithSigninToken(string signinTokenString, bool issueRefreshToken, CancellationToken ct); + /// The access token, refresh token, and nonce. + Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithSigninToken(string signinTokenString, bool shortLived, CancellationToken ct); /// /// Performs a login with a refresh token. @@ -40,24 +40,25 @@ public interface ILoginProvider /// The refresh token. /// The cancellation token. /// The access and refresh tokens. - Task<(string AccessToken, string RefreshToken)> PerformLoginWithRefreshToken(string refreshTokenString, CancellationToken ct); + Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct); /// /// Performs a login with a password. /// /// The password. - /// Whether to issue a refresh token. + /// Whether to issue a short-lived refresh token. /// The cancellation token. - /// The access and refresh tokens. - Task<(string AccessToken, string? RefreshToken)> PerformLoginWithPassword(string password, bool issueRefreshToken, CancellationToken ct); + /// The access token, refresh token, and nonce. + Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithPassword(string password, bool shortLived, CancellationToken ct); /// /// Performs a logout with a refresh token. /// /// The refresh token. + /// The nonce associated with the refresh token. /// The cancellation token. /// The task. - Task PerformLogoutWithRefreshToken(string refreshTokenString, CancellationToken ct); + Task PerformLogoutWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct); /// /// Performs a complete logout for a user. diff --git a/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs b/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs index c713c302b..24f212d03 100644 --- a/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs +++ b/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs @@ -38,7 +38,7 @@ public class DuplicatiServerClient : IDisposable private readonly string _baseUrl; private readonly JsonSerializerOptions _jsonOptions; private bool _disposed; - private readonly bool _selfOwnedHttpClient; + private readonly bool _selfOwnedHttpClient; private readonly SemaphoreSlim _tokenRefreshSemaphore = new(1, 1); private readonly ServerCredentialType _credentialType; private readonly string _credential; @@ -306,11 +306,12 @@ public class DuplicatiServerClient : IDisposable /// /// Refreshes the access token using the refresh token (V1). /// + /// The nonce to include in the refresh request. Optional, can be null. /// The cancellation token. Optional, defaults to . /// The new access token output. - public async Task RefreshTokenV1Async(CancellationToken cancellationToken = default) + public async Task RefreshTokenV1Async(string? nonce, CancellationToken cancellationToken = default) { - return await PostAsync("/api/v1/auth/refresh", null, cancellationToken, false).ConfigureAwait(false); + return await PostAsync("/api/v1/auth/refresh", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken, false).ConfigureAwait(false); } /// @@ -348,11 +349,12 @@ public class DuplicatiServerClient : IDisposable /// /// Logs out and invalidates the refresh token (V1). /// + /// The nonce to include in the logout request. /// The cancellation token. Optional, defaults to . /// A task representing the asynchronous operation. - public async Task LogoutV1Async(CancellationToken cancellationToken = default) + public async Task LogoutV1Async(string? nonce, CancellationToken cancellationToken = default) { - await PostAsync("/api/v1/auth/refresh/logout", null, cancellationToken).ConfigureAwait(false); + await PostAsync("/api/v1/auth/refresh/logout", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken).ConfigureAwait(false); } // V1 Backup Management Methods diff --git a/Duplicati/WebserverCore/Dto/AccessTokenOutputDto.cs b/Duplicati/WebserverCore/Dto/AccessTokenOutputDto.cs index 69664531c..bf36de7b4 100644 --- a/Duplicati/WebserverCore/Dto/AccessTokenOutputDto.cs +++ b/Duplicati/WebserverCore/Dto/AccessTokenOutputDto.cs @@ -20,4 +20,4 @@ // DEALINGS IN THE SOFTWARE. namespace Duplicati.WebserverCore.Dto; -public record AccessTokenOutputDto(string AccessToken); +public record AccessTokenOutputDto(string AccessToken, string? RefreshNonce); diff --git a/Duplicati/WebserverCore/Dto/RefreshTokenInputDto.cs b/Duplicati/WebserverCore/Dto/RefreshTokenInputDto.cs new file mode 100644 index 000000000..6f3a9a436 --- /dev/null +++ b/Duplicati/WebserverCore/Dto/RefreshTokenInputDto.cs @@ -0,0 +1,27 @@ +// 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. +namespace Duplicati.WebserverCore.Dto; + +/// +/// Represents the input for a refresh token operation. +/// +/// The nonce associated with the refresh token. +public sealed record RefreshTokenInputDto(string Nonce); diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index 3cb9e037e..362a04733 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -198,6 +198,9 @@ public class DuplicatiWebserver if (settings.TokenLifetimeInMinutes > 0) jwtConfig = jwtConfig with { RefreshTokenDurationInMinutes = settings.TokenLifetimeInMinutes }; + if (jwtConfig.PbkdfConfig == null) + jwtConfig = jwtConfig with { PbkdfConfig = Server.PbkdfConfig.Default }; + if (EnableSwagger) { // Swagger is picking up JSON settings from controllers, even though we do not use controllers diff --git a/Duplicati/WebserverCore/Endpoints/V1/Auth.cs b/Duplicati/WebserverCore/Endpoints/V1/Auth.cs index 3c7b6397a..56d395a92 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/Auth.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/Auth.cs @@ -40,16 +40,16 @@ public partial class Auth : IEndpointV1 public static void Map(RouteGroupBuilder group) { - group.MapPost("auth/refresh", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, CancellationToken ct) => + group.MapPost("auth/refresh", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.RefreshTokenInputDto? input, CancellationToken ct) => { var cookieName = GetCookieName(httpContextAccessor); if (httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var refreshTokenString)) { try { - var result = await loginProvider.PerformLoginWithRefreshToken(refreshTokenString, ct); + var result = await loginProvider.PerformLoginWithRefreshToken(refreshTokenString, input?.Nonce ?? "", ct); AddCookie(httpContextAccessor.HttpContext, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes)); - return new Dto.AccessTokenOutputDto(result.AccessToken); + return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce); } catch (Exception ex) { @@ -70,10 +70,10 @@ public partial class Auth : IEndpointV1 var cookieName = GetCookieName(httpContextAccessor); try { - var result = await loginProvider.PerformLoginWithSigninToken(input.SigninToken, input.RememberMe ?? false, ct); + var result = await loginProvider.PerformLoginWithSigninToken(input.SigninToken, !(input.RememberMe ?? false), ct); if (!string.IsNullOrWhiteSpace(result.RefreshToken)) AddCookie(httpContextAccessor.HttpContext!, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes)); - return new Dto.AccessTokenOutputDto(result.AccessToken); + return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce); } catch (Exception ex) { @@ -92,10 +92,10 @@ public partial class Auth : IEndpointV1 var cookieName = GetCookieName(httpContextAccessor); try { - var result = await loginProvider.PerformLoginWithPassword(input.Password, input.RememberMe ?? false, ct); + var result = await loginProvider.PerformLoginWithPassword(input.Password, !(input.RememberMe ?? false), ct); if (!string.IsNullOrWhiteSpace(result.RefreshToken)) AddCookie(httpContextAccessor.HttpContext!, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes)); - return new Dto.AccessTokenOutputDto(result.AccessToken); + return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce); } catch (Exception ex) { @@ -117,8 +117,8 @@ public partial class Auth : IEndpointV1 return new Dto.SigninTokenOutputDto(signinToken); }); - group.MapPost("auth/refresh/logout", ([FromServices] ILoginProvider loginProvider, [FromServices] IHttpContextAccessor httpContextAccessor) => - PerformLogout(loginProvider, httpContextAccessor)); + group.MapPost("auth/refresh/logout", ([FromServices] ILoginProvider loginProvider, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.RefreshTokenInputDto? input) => + PerformLogout(loginProvider, httpContextAccessor, input)); group.MapPost("auth/issuetoken/{operation}", ([FromServices] Connection connection, [FromServices] IJWTTokenProvider tokenProvider, [FromRoute] string operation) => { @@ -143,7 +143,7 @@ public partial class Auth : IEndpointV1 if (!res.Value) throw new UnauthorizedException("Cannot generate multiple forever tokens, restart the server to generate a new one"); - return new Dto.AccessTokenOutputDto(tokenProvider.CreateForeverToken()); + return new Dto.AccessTokenOutputDto(tokenProvider.CreateForeverToken(), null); }).RequireAuthorization(); } @@ -159,14 +159,14 @@ public partial class Auth : IEndpointV1 Domain = context.Request.Host.Host }); - private static object PerformLogout(ILoginProvider loginProvider, IHttpContextAccessor httpContextAccessor) + private static object PerformLogout(ILoginProvider loginProvider, IHttpContextAccessor httpContextAccessor, Dto.RefreshTokenInputDto? input) { var cookieName = GetCookieName(httpContextAccessor); if (httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var refreshTokenString)) { try { - loginProvider.PerformLogoutWithRefreshToken(refreshTokenString, CancellationToken.None); + loginProvider.PerformLogoutWithRefreshToken(refreshTokenString, input?.Nonce, CancellationToken.None); } catch { diff --git a/Duplicati/WebserverCore/Middlewares/JWTProvider.cs b/Duplicati/WebserverCore/Middlewares/JWTProvider.cs index 95abe8888..c057bb931 100644 --- a/Duplicati/WebserverCore/Middlewares/JWTProvider.cs +++ b/Duplicati/WebserverCore/Middlewares/JWTProvider.cs @@ -21,6 +21,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using Duplicati.Server; using Duplicati.WebserverCore.Abstractions; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.JsonWebTokens; @@ -35,17 +36,20 @@ public record JWTConfig public required string SigningKey { get; init; } public int AccessTokenDurationInMinutes { get; init; } = 15; public int RefreshTokenDurationInMinutes { get; init; } = 60 * 24 * 30; + public int RefreshTokenShortLivedDurationInMinutes { get; init; } = 60 * 2; public int SigninTokenDurationInMinutes { get; init; } = 5; public int SingleOperationTokenDurationInMinutes { get; init; } = 1; public int MaxRefreshTokenDrift { get; init; } = 1; public int MaxRefreshTokenDriftSeconds { get; init; } = 30; + public bool RequireRefreshNonce { get; init; } = true; public SymmetricSecurityKey SymmetricSecurityKey() => new(Encoding.UTF8.GetBytes(SigningKey)); + public PbkdfConfig PbkdfConfig { get; init; } = PbkdfConfig.Default; public static JWTConfig Create() => new() { Authority = "https://duplicati", Audience = "https://duplicati", - SigningKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) + SigningKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)), }; } @@ -67,27 +71,40 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider ], DateTime.Now, expires: DateTime.Now.AddMinutes(jWTConfig.SigninTokenDurationInMinutes)); public string CreateAccessToken(string userId, string tokenFamilyId, TimeSpan? expiration = null) - => GenerateToken([ + => GenerateToken([ new Claim(Claims.Type, TokenType.AccessToken.ToString()), new Claim(Claims.UserId, userId), new Claim(Claims.Family, tokenFamilyId) ], DateTime.Now, expires: DateTime.Now.AddMinutes(Math.Min(jWTConfig.AccessTokenDurationInMinutes, expiration?.TotalMinutes ?? jWTConfig.AccessTokenDurationInMinutes))); public string CreateForeverToken() - => GenerateToken([ + => 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([ + public (string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived) + { + var claims = new List + { new Claim(Claims.Type, TokenType.RefreshToken.ToString()), new Claim(Claims.UserId, userId), new Claim(Claims.Family, tokenFamilyId), new Claim(Claims.Counter, counter.ToString()), - new Claim(Claims.IssuedAt, (DateTime.UnixEpoch - DateTime.UtcNow).TotalSeconds.ToString()) - ], DateTime.Now, expires: DateTime.Now.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes)); + new Claim(Claims.IssuedAt, (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds.ToString()) + }; + + string? nonce = null; + if (shortLived || jWTConfig.RequireRefreshNonce) + { + nonce = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + claims.Add(new Claim(Claims.Nonce, jWTConfig.PbkdfConfig.WithPassword(nonce).Hash)); + } + + var refreshToken = GenerateToken(claims, DateTime.Now, expires: DateTime.Now.AddMinutes(shortLived ? jWTConfig.RefreshTokenShortLivedDurationInMinutes : jWTConfig.RefreshTokenDurationInMinutes)); + return (refreshToken, nonce); + } private string GenerateToken(IEnumerable claims, DateTime notBefore, DateTime expires) { @@ -131,9 +148,19 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider ); } - public IJWTTokenProvider.RefreshToken ReadRefreshToken(string token) + public IJWTTokenProvider.RefreshToken ReadRefreshToken(string token, string? nonce) { + // We rely on the fact that the caller cannot remove or modify the nonce after creation. + // If the token was created without a nonce, it will be accepted even with a mismatched nonce. var jwtToken = ParseAndValidateToken(token, TokenType.RefreshToken); + var validateNonce = jwtToken.Claims.FirstOrDefault(c => c.Type == Claims.Nonce); + if (validateNonce != null) + { + var nonceClaim = validateNonce.Value; + var pbkdf = jWTConfig.PbkdfConfig.WithHash(nonceClaim); + if (!pbkdf.VerifyPassword(nonce ?? "")) + throw new SecurityTokenValidationException("Refresh nonce does not match the expected value"); + } return new IJWTTokenProvider.RefreshToken( jwtToken.ValidFrom, @@ -223,6 +250,7 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider public const string Counter = "cnt"; public const string IssuedAt = "iat"; public const string Operation = "sop"; + public const string Nonce = "nce"; } } diff --git a/Duplicati/WebserverCore/Services/LoginProvider.cs b/Duplicati/WebserverCore/Services/LoginProvider.cs index b9501705c..5cffb2ade 100644 --- a/Duplicati/WebserverCore/Services/LoginProvider.cs +++ b/Duplicati/WebserverCore/Services/LoginProvider.cs @@ -30,24 +30,24 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid { private static readonly string LOGTAG = Log.LogTagFromType(); - public async Task<(string AccessToken, string? RefreshToken)> PerformLoginWithSigninToken(string signinTokenString, bool issueRefreshToken, CancellationToken ct) + public async Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithSigninToken(string signinTokenString, bool shortLived, CancellationToken ct) { var signinToken = tokenProvider.ReadSigninToken(signinTokenString); var userId = signinToken.UserId; - if (!issueRefreshToken) - return (tokenProvider.CreateAccessToken(userId, tokenProvider.TemporaryFamilyId), null); var tokenFamily = await repo.CreateTokenFamily(userId, ct); + var (refreshToken, nonce) = tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter, shortLived); return ( tokenProvider.CreateAccessToken(userId, tokenFamily.Id), - tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter) + refreshToken, + nonce ); } - public async Task<(string AccessToken, string RefreshToken)> PerformLoginWithRefreshToken(string refreshTokenString, CancellationToken ct) + public async Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct) { - var refreshToken = tokenProvider.ReadRefreshToken(refreshTokenString); + var refreshToken = tokenProvider.ReadRefreshToken(refreshTokenString, nonce); var tokenFamily = await repo.GetTokenFamily(refreshToken.UserId, refreshToken.TokenFamilyId, ct) ?? throw new UnauthorizedException("Invalid refresh token"); @@ -65,33 +65,35 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid } tokenFamily = await repo.IncrementTokenFamily(tokenFamily, ct); + var isShortLived = (refreshToken.Expiration - refreshToken.ValidFrom).TotalMinutes <= jwtConfig.RefreshTokenShortLivedDurationInMinutes + 1; + (var newRefreshToken, var newNonce) = tokenProvider.CreateRefreshToken(refreshToken.UserId, tokenFamily.Id, tokenFamily.Counter, isShortLived); return ( tokenProvider.CreateAccessToken(refreshToken.UserId, tokenFamily.Id), - tokenProvider.CreateRefreshToken(refreshToken.UserId, tokenFamily.Id, tokenFamily.Counter) + newRefreshToken, + newNonce ); } - public async Task<(string AccessToken, string? RefreshToken)> PerformLoginWithPassword(string password, bool issueRefreshToken, CancellationToken ct) + public async Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithPassword(string password, bool shortLived, CancellationToken ct) { if (!connection.ApplicationSettings.VerifyWebserverPassword(password)) throw new UnauthorizedException("Invalid password"); var userId = "webserver"; - if (!issueRefreshToken) - return (tokenProvider.CreateAccessToken(userId, tokenProvider.TemporaryFamilyId), null); - var tokenFamily = await repo.CreateTokenFamily(userId, ct); + var (refreshToken, nonce) = tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter, shortLived); return ( tokenProvider.CreateAccessToken(userId, tokenFamily.Id), - tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter) + refreshToken, + nonce ); } - public async Task PerformLogoutWithRefreshToken(string refreshTokenString, CancellationToken ct) + public async Task PerformLogoutWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct) { - var token = tokenProvider.ReadRefreshToken(refreshTokenString); + var token = tokenProvider.ReadRefreshToken(refreshTokenString, nonce); await repo.InvalidateTokenFamily(token.UserId, token.TokenFamilyId, ct); } diff --git a/WebserverCore.Client.UsageExample/Program.cs b/WebserverCore.Client.UsageExample/Program.cs index dfcf64b05..478419787 100644 --- a/WebserverCore.Client.UsageExample/Program.cs +++ b/WebserverCore.Client.UsageExample/Program.cs @@ -93,7 +93,7 @@ class Program // Refresh token (if using refresh tokens) try { - var refreshResult = await client.RefreshTokenV1Async(CancellationToken.None); + var refreshResult = await client.RefreshTokenV1Async(null, CancellationToken.None); Console.WriteLine($"✓ Token refreshed: {refreshResult.AccessToken[..10]}..."); } catch (Exception ex)