Make identity rehash persistence best effort

This commit is contained in:
Sipke Schoorstra 2026-05-20 13:50:36 +02:00
parent 95640cbf7d
commit 94df64b2b9
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
3 changed files with 121 additions and 4 deletions

View file

@ -2,6 +2,8 @@ using Elsa.Extensions;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Elsa.Identity.Services;
@ -13,16 +15,25 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal
private readonly IApplicationProvider _applicationProvider;
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, IApplicationStore applicationStore, ISecretHasher secretHasher)
public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, IApplicationStore applicationStore, ISecretHasher secretHasher) : this(apiKeyParser, applicationProvider, applicationStore, secretHasher, null)
{
}
/// <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)
{
_apiKeyParser = apiKeyParser;
_applicationProvider = applicationProvider;
_applicationStore = applicationStore;
_secretHasher = secretHasher;
_logger = logger ?? NullLogger<DefaultApplicationCredentialsValidator>.Instance;
}
/// <inheritdoc />
@ -47,7 +58,14 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal
var hashedApiKey = _secretHasher.HashSecret(apiKey);
application.HashedApiKey = hashedApiKey.EncodeSecret();
application.HashedApiKeySalt = hashedApiKey.EncodeSalt();
await _applicationStore.SaveAsync(application, cancellationToken);
try
{
await _applicationStore.SaveAsync(application, cancellationToken);
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to save upgraded API key hash for application {ApplicationId}.", application.Id);
}
}
return application;

View file

@ -1,6 +1,8 @@
using Elsa.Extensions;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Elsa.Identity.Services;
@ -12,15 +14,24 @@ public class DefaultUserCredentialsValidator : IUserCredentialsValidator
private readonly IUserProvider _userProvider;
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, IUserStore userStore, ISecretHasher secretHasher)
public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher) : this(userProvider, userStore, secretHasher, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DefaultUserCredentialsValidator"/> class.
/// </summary>
public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher, ILogger<DefaultUserCredentialsValidator>? logger)
{
_userProvider = userProvider;
_userStore = userStore;
_secretHasher = secretHasher;
_logger = logger ?? NullLogger<DefaultUserCredentialsValidator>.Instance;
}
/// <inheritdoc />
@ -41,7 +52,14 @@ public class DefaultUserCredentialsValidator : IUserCredentialsValidator
var hashedPassword = _secretHasher.HashSecret(password);
user.HashedPassword = hashedPassword.EncodeSecret();
user.HashedPasswordSalt = hashedPassword.EncodeSalt();
await _userStore.SaveAsync(user, cancellationToken);
try
{
await _userStore.SaveAsync(user, cancellationToken);
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to save upgraded password hash for user {UserId}.", user.Id);
}
}
return user;

View file

@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using Elsa.Common.Services;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Providers;
@ -117,6 +118,50 @@ public class DefaultSecretHasherTests
Assert.StartsWith("pbkdf2-sha256$", Encoding.UTF8.GetString(Convert.FromBase64String(reloadedApplication.HashedApiKey)));
}
[Fact]
public async Task ValidateAsync_ReturnsUserWhenLegacyPasswordRehashSaveFails()
{
var legacyHash = CreateLegacyHash("secret");
var user = new User
{
Id = "user-1",
Name = "alice",
HashedPassword = legacyHash.EncodeSecret(),
HashedPasswordSalt = legacyHash.EncodeSalt()
};
var userStore = new FailingUserStore(user);
var validator = new DefaultUserCredentialsValidator(new StoreBasedUserProvider(userStore), userStore, _hasher);
var validatedUser = await validator.ValidateAsync("alice", "secret");
Assert.Same(user, validatedUser);
}
[Fact]
public async Task ValidateAsync_ReturnsApplicationWhenLegacyApiKeyRehashSaveFails()
{
var apiKeyGenerator = new DefaultApiKeyGeneratorAndParser();
var apiKey = apiKeyGenerator.Generate("client-1");
var legacyHash = CreateLegacyHash(apiKey);
var application = new Application
{
Id = "app-1",
ClientId = "client-1",
Name = "Client 1",
HashedApiKey = legacyHash.EncodeSecret(),
HashedApiKeySalt = legacyHash.EncodeSalt(),
HashedClientSecret = "",
HashedClientSecretSalt = ""
};
var applicationStore = new FailingApplicationStore(application);
var applicationProvider = new StoreBasedApplicationProvider(applicationStore);
var validator = new DefaultApplicationCredentialsValidator(apiKeyGenerator, applicationProvider, applicationStore, _hasher);
var validatedApplication = await validator.ValidateAsync(apiKey);
Assert.Same(application, validatedApplication);
}
private static HashedSecret CreateLegacyHash(string secret)
{
var salt = RandomNumberGenerator.GetBytes(32);
@ -133,4 +178,40 @@ public class DefaultSecretHasherTests
var envelope = Encoding.UTF8.GetBytes($"pbkdf2-sha256${iterationCount}${Convert.ToBase64String(hash)}");
return HashedSecret.FromBytes(envelope, salt);
}
private sealed class FailingUserStore(User user) : IUserStore
{
private readonly User _user = user;
public Task SaveAsync(User user, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Save failed.");
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var users = filter.Apply(new[] { _user }.AsQueryable()).ToList();
return Task.FromResult<IEnumerable<User>>(users);
}
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = filter.Apply(new[] { _user }.AsQueryable()).FirstOrDefault();
return Task.FromResult(user);
}
}
private sealed class FailingApplicationStore(Application application) : IApplicationStore
{
private readonly Application _application = application;
public Task SaveAsync(Application application, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Save failed.");
public Task DeleteAsync(ApplicationFilter filter, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Application?> FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default)
{
var application = filter.Apply(new[] { _application }.AsQueryable()).FirstOrDefault();
return Task.FromResult(application);
}
}
}