Merge pull request #5521 from duplicati/feature/allow-jwt-token-drift
Added support for drifts in JWT refresh tokens.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user