Merge pull request #5521 from duplicati/feature/allow-jwt-token-drift

Added support for drifts in JWT refresh tokens.
This commit is contained in:
Kenneth Skovhede
2024-09-02 13:00:50 +02:00
committed by GitHub
4 changed files with 22 additions and 10 deletions
@@ -53,6 +53,7 @@ public interface ITokenFamilyStore
/// </summary>
/// <param name="Id">The ID.</param>
/// <param name="UserId">The user ID.</param>
/// <param name="Counter">The counter.</param>
public record TokenFamily(string Id, string UserId, int Counter);
/// <param name="Counter">The counter.</param>
/// <param name="LastUpdated">The last updated timestamp.</param>
public record TokenFamily(string Id, string UserId, int Counter, DateTime LastUpdated);
}
@@ -17,6 +17,8 @@ public record JWTConfig
public int RefreshTokenDurationInMinutes { get; init; } = 60 * 24 * 30;
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 SymmetricSecurityKey SymmetricSecurityKey() => new(Encoding.UTF8.GetBytes(SigningKey));
public static JWTConfig Create() => new()
@@ -1,10 +1,11 @@
using Duplicati.Library.Logging;
using Duplicati.Server.Database;
using Duplicati.WebserverCore.Abstractions;
using Duplicati.WebserverCore.Middlewares;
namespace Duplicati.WebserverCore.Services;
public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvider, Connection connection) : ILoginProvider
public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvider, JWTConfig jwtConfig, Connection connection) : ILoginProvider
{
private static readonly string LOGTAG = Log.LogTagFromType<LoginProvider>();
@@ -29,7 +30,13 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid
var tokenFamily = await repo.GetTokenFamily(refreshToken.UserId, refreshToken.TokenFamilyId, ct)
?? throw new UnauthorizedAccessException("Invalid refresh token");
if (tokenFamily.Counter != refreshToken.Counter)
// Allow slight drift to adjust for cases where the browser refreshes
// just before the token is received, so the server is ahead
var counterDiff = tokenFamily.Counter - refreshToken.Counter;
var maxDrift = (DateTime.UtcNow - tokenFamily.LastUpdated).TotalSeconds > jwtConfig.MaxRefreshTokenDriftSeconds
? 0
: jwtConfig.MaxRefreshTokenDrift;
if (counterDiff < 0 || counterDiff > maxDrift)
{
Log.WriteWarningMessage(LOGTAG, "TokenFamilyReuse", null, $"Invalid refresh token counter: {tokenFamily.Counter} != {refreshToken.Counter}");
await repo.InvalidateTokenFamily(tokenFamily.UserId, tokenFamily.Id, ct);
@@ -11,17 +11,18 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
{
var familyId = System.Security.Cryptography.RandomNumberGenerator.GetHexString(16);
var counter = System.Security.Cryptography.RandomNumberGenerator.GetInt32(1024) % 1024;
var lastUpdated = DateTime.UtcNow;
connection.ExecuteWithCommand(cmd =>
{
cmd.CommandText = @"INSERT INTO TokenFamily (""Id"", ""UserId"", ""Counter"", ""LastUpdated"") VALUES (?, ?, ?, ?)";
cmd.AddParameter(familyId);
cmd.AddParameter(userId);
cmd.AddParameter(counter);
cmd.AddParameter(DateTime.UtcNow.Ticks);
cmd.AddParameter(lastUpdated.Ticks);
cmd.ExecuteNonQuery();
});
return Task.FromResult(new ITokenFamilyStore.TokenFamily(familyId, userId, counter));
return Task.FromResult(new ITokenFamilyStore.TokenFamily(familyId, userId, counter, lastUpdated));
}
public Task<ITokenFamilyStore.TokenFamily> GetTokenFamily(string userId, string familyId, CancellationToken ct)
@@ -29,14 +30,14 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
ITokenFamilyStore.TokenFamily? family = null;
connection.ExecuteWithCommand(cmd =>
{
cmd.CommandText = @"SELECT ""Id"", ""UserId"", ""Counter"" FROM ""TokenFamily"" WHERE ""Id"" = ? AND ""UserId"" = ?";
cmd.CommandText = @"SELECT ""Id"", ""UserId"", ""Counter"", ""LastUpdated"" FROM ""TokenFamily"" WHERE ""Id"" = ? AND ""UserId"" = ?";
cmd.AddParameter(familyId);
cmd.AddParameter(userId);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return;
family = new ITokenFamilyStore.TokenFamily(reader.GetString(0), reader.GetString(1), reader.GetInt32(2));
family = new ITokenFamilyStore.TokenFamily(reader.GetString(0), reader.GetString(1), reader.GetInt32(2), new DateTime(reader.GetInt64(3)));
});
return Task.FromResult(family ?? throw new Exceptions.UnauthorizedException("Token family not found"));
}
@@ -44,11 +45,12 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
public Task<ITokenFamilyStore.TokenFamily> IncrementTokenFamily(ITokenFamilyStore.TokenFamily tokenFamily, CancellationToken ct)
{
var nextCounter = tokenFamily.Counter + 1;
var lastUpdated = DateTime.UtcNow;
connection.ExecuteWithCommand(cmd =>
{
cmd.CommandText = @"UPDATE ""TokenFamily"" SET ""Counter"" = ?, ""LastUpdated"" = ? WHERE ""Id"" = ? AND ""UserId"" = ? AND ""Counter"" = ?";
cmd.AddParameter(nextCounter);
cmd.AddParameter(DateTime.UtcNow.Ticks);
cmd.AddParameter(lastUpdated.Ticks);
cmd.AddParameter(tokenFamily.Id);
cmd.AddParameter(tokenFamily.UserId);
cmd.AddParameter(tokenFamily.Counter);
@@ -56,7 +58,7 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
throw new Exceptions.ConflictException("Token family counter mismatch or not found");
});
return Task.FromResult(new ITokenFamilyStore.TokenFamily(tokenFamily.Id, tokenFamily.UserId, nextCounter));
return Task.FromResult(new ITokenFamilyStore.TokenFamily(tokenFamily.Id, tokenFamily.UserId, nextCounter, lastUpdated));
}
public Task InvalidateTokenFamily(string userId, string familyId, CancellationToken ct)