From 94df64b2b9ccf40e7a37543d96e7efb6b3b642d7 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 May 2026 13:50:36 +0200 Subject: [PATCH] Make identity rehash persistence best effort --- .../DefaultApplicationCredentialsValidator.cs | 22 ++++- .../DefaultUserCredentialsValidator.cs | 22 ++++- .../Services/DefaultSecretHasherTests.cs | 81 +++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs b/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs index 176f98add..148396d45 100644 --- a/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs +++ b/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs @@ -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 _logger; /// /// Initializes a new instance of the class. /// - 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) + { + } + + /// + /// Initializes a new instance of the class. + /// + public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, IApplicationStore applicationStore, ISecretHasher secretHasher, ILogger? logger) { _apiKeyParser = apiKeyParser; _applicationProvider = applicationProvider; _applicationStore = applicationStore; _secretHasher = secretHasher; + _logger = logger ?? NullLogger.Instance; } /// @@ -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; diff --git a/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs b/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs index 704e6773b..3aa880091 100644 --- a/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs +++ b/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs @@ -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 _logger; /// /// Initializes a new instance of the class. /// - public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher) + public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher) : this(userProvider, userStore, secretHasher, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher, ILogger? logger) { _userProvider = userProvider; _userStore = userStore; _secretHasher = secretHasher; + _logger = logger ?? NullLogger.Instance; } /// @@ -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; diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs index 7aabeebea..2012864e9 100644 --- a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs @@ -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> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) + { + var users = filter.Apply(new[] { _user }.AsQueryable()).ToList(); + return Task.FromResult>(users); + } + + public Task 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 FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default) + { + var application = filter.Apply(new[] { _application }.AsQueryable()).FirstOrDefault(); + return Task.FromResult(application); + } + } }