Address identity validator compatibility feedback
This commit is contained in:
parent
9415d220c0
commit
e2587f74b5
|
|
@ -6,7 +6,7 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3
|
|||
|
||||
## ⚠️ Breaking changes / upgrade notes
|
||||
|
||||
- **Identity secret hashing hardened**: New user passwords, client secrets, and API keys are hashed with PBKDF2-SHA256 using 600,000 iterations, per-record salts, and version metadata instead of fast SHA-256 hashes. Existing legacy hashes still verify and are upgraded opportunistically after successful login/API-key validation. Generated identity secrets now use cryptographic randomness. `DefaultUserCredentialsValidator` and `DefaultApplicationCredentialsValidator` now require `IUserStore` and `IApplicationStore` respectively; update direct instantiations to pass these additional dependencies.
|
||||
- **Identity secret hashing hardened**: New user passwords, client secrets, and API keys are hashed with PBKDF2-SHA256 using 600,000 iterations, per-record salts, and version metadata instead of fast SHA-256 hashes. Existing legacy hashes still verify and are upgraded opportunistically after successful login/API-key validation. Generated identity secrets now use cryptographic randomness. New `DefaultUserCredentialsValidator` and `DefaultApplicationCredentialsValidator` constructor overloads accept `IUserStore` and `IApplicationStore` respectively to persist opportunistic hash upgrades; existing direct instantiations remain supported but do not persist rehashes unless the store dependency is supplied.
|
||||
|
||||
- **EF Core package names have changed**: EF Core persistence packages were renamed from `Elsa.EntityFrameworkCore.*` to `Elsa.Persistence.EFCore.*`. If your application references any of the old package names, you must update them to the new package names when upgrading to 3.6.0. Be sure to review your project files, internal package feeds, CI pipelines, and deployment manifests for old package references.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,17 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal
|
|||
{
|
||||
private readonly IApiKeyParser _apiKeyParser;
|
||||
private readonly IApplicationProvider _applicationProvider;
|
||||
private readonly IApplicationStore _applicationStore;
|
||||
private readonly IApplicationStore? _applicationStore;
|
||||
private readonly ISecretHasher _secretHasher;
|
||||
private readonly ILogger<DefaultApplicationCredentialsValidator> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultApplicationCredentialsValidator"/> class.
|
||||
/// </summary>
|
||||
public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, ISecretHasher secretHasher) : this(apiKeyParser, applicationProvider, null, secretHasher, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultApplicationCredentialsValidator"/> class.
|
||||
/// </summary>
|
||||
|
|
@ -27,7 +34,7 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal
|
|||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultApplicationCredentialsValidator"/> class.
|
||||
/// </summary>
|
||||
public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, IApplicationStore applicationStore, ISecretHasher secretHasher, ILogger<DefaultApplicationCredentialsValidator>? logger)
|
||||
public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, IApplicationStore? applicationStore, ISecretHasher secretHasher, ILogger<DefaultApplicationCredentialsValidator>? logger)
|
||||
{
|
||||
_apiKeyParser = apiKeyParser;
|
||||
_applicationProvider = applicationProvider;
|
||||
|
|
@ -53,7 +60,7 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal
|
|||
if (!isValidApiKey)
|
||||
return null;
|
||||
|
||||
if (needsRehash)
|
||||
if (needsRehash && _applicationStore != null)
|
||||
{
|
||||
var hashedApiKey = _secretHasher.HashSecret(apiKey);
|
||||
application.HashedApiKey = hashedApiKey.EncodeSecret();
|
||||
|
|
|
|||
|
|
@ -107,7 +107,12 @@ public class DefaultSecretHasher : ISecretHasher
|
|||
try
|
||||
{
|
||||
hash = Convert.FromBase64String(segments[2]);
|
||||
return true;
|
||||
if (hash.Length == KeySize)
|
||||
return true;
|
||||
|
||||
iterationCount = 0;
|
||||
hash = [];
|
||||
return false;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,10 +12,17 @@ namespace Elsa.Identity.Services;
|
|||
public class DefaultUserCredentialsValidator : IUserCredentialsValidator
|
||||
{
|
||||
private readonly IUserProvider _userProvider;
|
||||
private readonly IUserStore _userStore;
|
||||
private readonly IUserStore? _userStore;
|
||||
private readonly ISecretHasher _secretHasher;
|
||||
private readonly ILogger<DefaultUserCredentialsValidator> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultUserCredentialsValidator"/> class.
|
||||
/// </summary>
|
||||
public DefaultUserCredentialsValidator(IUserProvider userProvider, ISecretHasher secretHasher) : this(userProvider, null, secretHasher, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultUserCredentialsValidator"/> class.
|
||||
/// </summary>
|
||||
|
|
@ -26,7 +33,7 @@ public class DefaultUserCredentialsValidator : IUserCredentialsValidator
|
|||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultUserCredentialsValidator"/> class.
|
||||
/// </summary>
|
||||
public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher, ILogger<DefaultUserCredentialsValidator>? logger)
|
||||
public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore? userStore, ISecretHasher secretHasher, ILogger<DefaultUserCredentialsValidator>? logger)
|
||||
{
|
||||
_userProvider = userProvider;
|
||||
_userStore = userStore;
|
||||
|
|
@ -47,7 +54,7 @@ public class DefaultUserCredentialsValidator : IUserCredentialsValidator
|
|||
if (!isValidPassword)
|
||||
return null;
|
||||
|
||||
if (needsRehash)
|
||||
if (needsRehash && _userStore != null)
|
||||
{
|
||||
var hashedPassword = _secretHasher.HashSecret(password);
|
||||
user.HashedPassword = hashedPassword.EncodeSecret();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,19 @@ public class DefaultSecretHasherTests
|
|||
Assert.False(needsRehash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySecret_RejectsPbkdf2HashWithInvalidKeyLength()
|
||||
{
|
||||
var salt = _hasher.GenerateSalt();
|
||||
var storedHash = Encoding.UTF8.GetBytes("pbkdf2-sha256$600000$" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)));
|
||||
var hashedSecret = HashedSecret.FromBytes(storedHash, salt);
|
||||
|
||||
var verified = _hasher.VerifySecret("secret", hashedSecret, out var needsRehash);
|
||||
|
||||
Assert.False(verified);
|
||||
Assert.False(needsRehash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RehashesLegacyUserPassword()
|
||||
{
|
||||
|
|
@ -137,6 +150,26 @@ public class DefaultSecretHasherTests
|
|||
Assert.Same(user, validatedUser);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WithOldUserValidatorConstructor_ReturnsUserWithoutPersistingRehash()
|
||||
{
|
||||
var legacyHash = CreateLegacyHash("secret");
|
||||
var encodedLegacyHash = legacyHash.EncodeSecret();
|
||||
var user = new User
|
||||
{
|
||||
Id = "user-1",
|
||||
Name = "alice",
|
||||
HashedPassword = encodedLegacyHash,
|
||||
HashedPasswordSalt = legacyHash.EncodeSalt()
|
||||
};
|
||||
var validator = new DefaultUserCredentialsValidator(new StaticUserProvider(user), _hasher);
|
||||
|
||||
var validatedUser = await validator.ValidateAsync("alice", "secret");
|
||||
|
||||
Assert.Same(user, validatedUser);
|
||||
Assert.Equal(encodedLegacyHash, user.HashedPassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsApplicationWhenLegacyApiKeyRehashSaveFails()
|
||||
{
|
||||
|
|
@ -162,6 +195,32 @@ public class DefaultSecretHasherTests
|
|||
Assert.Same(application, validatedApplication);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WithOldApplicationValidatorConstructor_ReturnsApplicationWithoutPersistingRehash()
|
||||
{
|
||||
var apiKeyGenerator = new DefaultApiKeyGeneratorAndParser();
|
||||
var apiKey = apiKeyGenerator.Generate("client-1");
|
||||
var legacyHash = CreateLegacyHash(apiKey);
|
||||
var encodedLegacyHash = legacyHash.EncodeSecret();
|
||||
var application = new Application
|
||||
{
|
||||
Id = "app-1",
|
||||
ClientId = "client-1",
|
||||
Name = "Client 1",
|
||||
HashedApiKey = encodedLegacyHash,
|
||||
HashedApiKeySalt = legacyHash.EncodeSalt(),
|
||||
HashedClientSecret = "",
|
||||
HashedClientSecretSalt = ""
|
||||
};
|
||||
var applicationProvider = new StaticApplicationProvider(application);
|
||||
var validator = new DefaultApplicationCredentialsValidator(apiKeyGenerator, applicationProvider, _hasher);
|
||||
|
||||
var validatedApplication = await validator.ValidateAsync(apiKey);
|
||||
|
||||
Assert.Same(application, validatedApplication);
|
||||
Assert.Equal(encodedLegacyHash, application.HashedApiKey);
|
||||
}
|
||||
|
||||
private static HashedSecret CreateLegacyHash(string secret)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(32);
|
||||
|
|
@ -214,4 +273,26 @@ public class DefaultSecretHasherTests
|
|||
return Task.FromResult(application);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticUserProvider(User user) : IUserProvider
|
||||
{
|
||||
private readonly User _user = user;
|
||||
|
||||
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = filter.Apply(new[] { _user }.AsQueryable()).FirstOrDefault();
|
||||
return Task.FromResult(user);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticApplicationProvider(Application application) : IApplicationProvider
|
||||
{
|
||||
private readonly Application _application = application;
|
||||
|
||||
public Task<Application?> FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var application = filter.Apply(new[] { _application }.AsQueryable()).FirstOrDefault();
|
||||
return Task.FromResult(application);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue