Implemented a nonce for refresh tokens

This adds a nonce to the refresh token such that each request to obtain a refresh token must now also provide a matching nonce.

When using non-persisted logins, the request to the server is the same, but the "remember me" flag toggles a shorter duration for the refresh token.

The FE can then store the nonce in either local storage for persisted logins or in session storage for non-persisted logins.

The default is currently to always issue refresh tokens with a nonce, but this can be toggled with the JWT configuration.

The ngax client does not have the non-persisted login so it stores the nonce in local storage, using a name that is compatible with ngclient so the user can swap between them without needing to re-login.

The server util was updated to also store the nonce.

This fixes #6451
This commit is contained in:
Kenneth Skovhede
2025-08-07 23:10:54 +02:00
parent a5c8dce21c
commit e3f1aefec2
18 changed files with 276 additions and 128 deletions
+19 -14
View File
@@ -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
/// <param name="password">The password to use</param>
/// <param name="obtainRefreshToken">Whether to obtain a refresh token</param>
/// <returns>The access and refresh tokens</returns>
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
/// </summary>
/// <param name="client">The HTTP client</param>
/// <param name="refreshToken">The refresh token to use</param>
/// <returns>The access and refresh tokens</returns>
private static Task<(string AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken)
=> ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh")
/// <returns>The access token, refresh tokens, and nonce</returns>
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 })
}));
}
/// <summary>
@@ -318,7 +321,7 @@ public class Connection
/// </summary>
/// <param name="response">The response to parse</param>
/// <returns>The access and refresh tokens</returns>
private static async Task<(string AccessToken, string? RefreshToken)> ParseAuthResponse(Task<HttpResponseMessage> responseTask)
private static async Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> ParseAuthResponse(Task<HttpResponseMessage> 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);
}
/// <summary>
@@ -561,7 +566,7 @@ public class Connection
/// <returns>The token</returns>
public async Task<string> 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;
}
+6 -2
View File
@@ -36,6 +36,7 @@ namespace Duplicati.CommandLine.ServerUtil;
/// </summary>
/// <param name="Password">The commandline password</param>
/// <param name="RefreshToken">The saved refresh token</param>
/// <param name="RefreshNonce">The saved refresh nonce</param>
/// <param name="HostUrl">The host url to connect to</param>
/// <param name="SettingsFile">The settings file where data is loaded/saved</param>
/// <param name="Insecure">Whether to disable TLS/SSL certificate trust check</param>
@@ -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(
/// <param name="ServerDatafolder">The server datafolder, if any</param>
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);
}
/// <summary>
/// Gets a connection to the server
/// </summary>
@@ -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;
/// <summary>
/// This class is used to store the PBKDF configuration parameters
/// </summary>
public record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash)
{
/// <summary>
/// The version to embed in the configuration
/// </summary>
private const int _Version = 1;
/// <summary>
/// The algorithm to use
/// </summary>
private const string _Algorithm = "PBKDF2";
/// <summary>
/// The hash algorithm to use
/// </summary>
private const string _HashAlorithm = "SHA256";
/// <summary>
/// The number of iterations to use
/// </summary>
private const int _Iterations = 10000;
/// <summary>
/// The size of the hash
/// </summary>
private const int _HashSize = 32;
/// <summary>
/// Creates a default PBKDF2 configuration
/// </summary>
public static PbkdfConfig Default => new PbkdfConfig(_Algorithm, _Version, string.Empty, _Iterations, _HashAlorithm, string.Empty);
/// <summary>
/// Creates a new PBKDF2 configuration with a random salt
/// </summary>
/// <param name="password">The password to hash</param>
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);
}
/// <summary>
/// Calculates the hash for the given password using the current configuration
/// </summary>
/// <param name="password">The password to hash</param>
/// <returns>The hashed password</returns>
private string ComputeHash(string password)
{
var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm));
return Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
}
/// <summary>
/// Creates a new PBKDF2 configuration with the given password
/// </summary>
/// <param name="password">The password to use</param>
/// <returns>The updated PBKDF2 configuration</returns>
public PbkdfConfig WithPassword(string password)
=> WithHash(ComputeHash(password));
/// <summary>
/// Creates a new PBKDF2 configuration with the given hash
/// </summary>
/// <param name="hash">The hash to use</param>
/// <returns>The updated PBKDF2 configuration</returns>
public PbkdfConfig WithHash(string hash)
=> this with { Hash = hash };
/// <summary>
/// Verifies a password against a PBKDF2 configuration
/// </summary>
/// <param name="password">The password to verify</param>
/// <returns>True if the password matches the configuration</returns>
public bool VerifyPassword(string password)
=> CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(Hash), Convert.FromBase64String(ComputeHash(password)));
}
@@ -330,63 +330,6 @@ namespace Duplicati.Server.Database
}
}
/// <summary>
/// This class is used to store the PBKDF configuration parameters
/// </summary>
private record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash)
{
/// <summary>
/// The version to embed in the configuration
/// </summary>
private const int _Version = 1;
/// <summary>
/// The algorithm to use
/// </summary>
private const string _Algorithm = "PBKDF2";
/// <summary>
/// The hash algorithm to use
/// </summary>
private const string _HashAlorithm = "SHA256";
/// <summary>
/// The number of iterations to use
/// </summary>
private const int _Iterations = 10000;
/// <summary>
/// The size of the hash
/// </summary>
private const int _HashSize = 32;
/// <summary>
/// Creates a new PBKDF2 configuration with a random salt
/// </summary>
/// <param name="password">The password to hash</param>
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);
}
/// <summary>
/// Verifies a password against a PBKDF2 configuration
/// </summary>
/// <param name="password">The password to verify</param>
/// <returns>True if the password matches the configuration</returns>
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;
}
}
/// <summary>
/// Verifies a password against the stored PBKDF configuration
/// </summary>
+2
View File
@@ -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) {
@@ -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);
};
@@ -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
+9 -1
View File
@@ -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) {
@@ -90,8 +90,9 @@ public interface IJWTTokenProvider
/// <param name="userId">The user ID the token is for.</param>
/// <param name="tokenFamilyId">The token family ID the token is for.</param>
/// <param name="counter">The counter of the token family the token is for.</param>
/// <returns>The JWT token.</returns>
string CreateRefreshToken(string userId, string tokenFamilyId, int counter);
/// <param name="shortLived">Whether the token should be short-lived.</param>
/// <returns>The JWT token and the nonce.</returns>
(string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived);
/// <summary>
/// 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.
/// </summary>
/// <param name="token">The JWT token.</param>
/// <param name="nonce">The nonce used to validate the token.</param>
/// <returns>The parsed and validated refresh token.</returns>
RefreshToken ReadRefreshToken(string token);
RefreshToken ReadRefreshToken(string token, string? nonce);
/// <summary>
/// Gets the family ID from a JWT token with no family counter.
@@ -29,10 +29,10 @@ public interface ILoginProvider
/// Performs a login with a signin token.
/// </summary>
/// <param name="signinTokenString">The signin token.</param>
/// <param name="issueRefreshToken">Whether to issue a refresh token.</param>
/// <param name="shortLived">Whether to issue a short-lived refresh token.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The access and refresh tokens.</returns>
Task<(string AccessToken, string? RefreshToken)> PerformLoginWithSigninToken(string signinTokenString, bool issueRefreshToken, CancellationToken ct);
/// <returns>The access token, refresh token, and nonce.</returns>
Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithSigninToken(string signinTokenString, bool shortLived, CancellationToken ct);
/// <summary>
/// Performs a login with a refresh token.
@@ -40,24 +40,25 @@ public interface ILoginProvider
/// <param name="refreshTokenString">The refresh token.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The access and refresh tokens.</returns>
Task<(string AccessToken, string RefreshToken)> PerformLoginWithRefreshToken(string refreshTokenString, CancellationToken ct);
Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct);
/// <summary>
/// Performs a login with a password.
/// </summary>
/// <param name="password">The password.</param>
/// <param name="issueRefreshToken">Whether to issue a refresh token.</param>
/// <param name="shortLived">Whether to issue a short-lived refresh token.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The access and refresh tokens.</returns>
Task<(string AccessToken, string? RefreshToken)> PerformLoginWithPassword(string password, bool issueRefreshToken, CancellationToken ct);
/// <returns>The access token, refresh token, and nonce.</returns>
Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithPassword(string password, bool shortLived, CancellationToken ct);
/// <summary>
/// Performs a logout with a refresh token.
/// </summary>
/// <param name="refreshTokenString">The refresh token.</param>
/// <param name="nonce">The nonce associated with the refresh token.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The task.</returns>
Task PerformLogoutWithRefreshToken(string refreshTokenString, CancellationToken ct);
Task PerformLogoutWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct);
/// <summary>
/// Performs a complete logout for a user.
@@ -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
/// <summary>
/// Refreshes the access token using the refresh token (V1).
/// </summary>
/// <param name="nonce">The nonce to include in the refresh request. Optional, can be null.</param>
/// <param name="cancellationToken">The cancellation token. Optional, defaults to <see cref="CancellationToken.None"/>.</param>
/// <returns>The new access token output.</returns>
public async Task<AccessTokenOutputDto> RefreshTokenV1Async(CancellationToken cancellationToken = default)
public async Task<AccessTokenOutputDto> RefreshTokenV1Async(string? nonce, CancellationToken cancellationToken = default)
{
return await PostAsync<AccessTokenOutputDto>("/api/v1/auth/refresh", null, cancellationToken, false).ConfigureAwait(false);
return await PostAsync<AccessTokenOutputDto>("/api/v1/auth/refresh", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken, false).ConfigureAwait(false);
}
/// <summary>
@@ -348,11 +349,12 @@ public class DuplicatiServerClient : IDisposable
/// <summary>
/// Logs out and invalidates the refresh token (V1).
/// </summary>
/// <param name="nonce">The nonce to include in the logout request.</param>
/// <param name="cancellationToken">The cancellation token. Optional, defaults to <see cref="CancellationToken.None"/>.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task LogoutV1Async(CancellationToken cancellationToken = default)
public async Task LogoutV1Async(string? nonce, CancellationToken cancellationToken = default)
{
await PostAsync<object>("/api/v1/auth/refresh/logout", null, cancellationToken).ConfigureAwait(false);
await PostAsync<object>("/api/v1/auth/refresh/logout", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken).ConfigureAwait(false);
}
// V1 Backup Management Methods
@@ -20,4 +20,4 @@
// DEALINGS IN THE SOFTWARE.
namespace Duplicati.WebserverCore.Dto;
public record AccessTokenOutputDto(string AccessToken);
public record AccessTokenOutputDto(string AccessToken, string? RefreshNonce);
@@ -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;
/// <summary>
/// Represents the input for a refresh token operation.
/// </summary>
/// <param name="Nonce">The nonce associated with the refresh token.</param>
public sealed record RefreshTokenInputDto(string Nonce);
@@ -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
+12 -12
View File
@@ -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
{
@@ -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<Claim>
{
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<Claim> 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";
}
}
@@ -30,24 +30,24 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid
{
private static readonly string LOGTAG = Log.LogTagFromType<LoginProvider>();
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);
}
+1 -1
View File
@@ -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)