From 5194e2c12d44cf1e560deeb4d36e22a1f8b36f32 Mon Sep 17 00:00:00 2001 From: kavics Date: Wed, 4 Nov 2026 02:54:06 +0100 Subject: [PATCH] Invalidate local authentication sessions on password changes and serialize per-user authentication operations --- docs/local-authentication.md | 27 +- .../ILocalAuthenticationUserLock.cs | 8 +- .../LocalAuthenticationExtensions.cs | 4 +- .../LocalAuthenticationFlows.cs | 30 +- .../RepositoryLocalUsers.cs | 14 +- .../InMemoryExtensions.cs | 2 + .../InMemoryUserAuthenticationLock.cs | 30 ++ .../MsSqlExtensions.cs | 2 + .../MsSqlUserAuthenticationLock.cs | 50 +++ src/ContentRepository/User.cs | 41 ++- src/Storage/Node.cs | 16 + .../Security/IUserAuthenticationLock.cs | 12 + .../Security/UserAuthenticationScope.cs | 55 ++++ .../LocalAuthenticationTests.cs | 8 +- .../PasswordChangeSessionTests.cs | 309 ++++++++++++++++++ ...SenseNet.Authentication.Local.Tests.csproj | 1 + .../SqlUserLockTests.cs | 16 - .../SqlLocalAuthenticationUserLock.cs | 44 +-- .../Startup.cs | 1 - 19 files changed, 572 insertions(+), 98 deletions(-) create mode 100644 src/ContentRepository.InMemory/InMemoryUserAuthenticationLock.cs create mode 100644 src/ContentRepository.MsSql/MsSqlUserAuthenticationLock.cs create mode 100644 src/Storage/Security/IUserAuthenticationLock.cs create mode 100644 src/Storage/Security/UserAuthenticationScope.cs create mode 100644 src/Tests/SenseNet.Authentication.Local.Tests/PasswordChangeSessionTests.cs diff --git a/docs/local-authentication.md b/docs/local-authentication.md index f0eea3fce..7239481c5 100644 --- a/docs/local-authentication.md +++ b/docs/local-authentication.md @@ -14,8 +14,10 @@ Its checked-in mode is `Disabled`; enable it explicitly in deployment configurat For a custom host, reference the module and call `services.AddSenseNetLocalAuthentication(configuration)` **after** registering the external -scheme. Register an ILocalAuthenticationUserLock implementation that serializes each user -across all replicas of the repository. Missing lock registration fails startup. Call `app.UseSenseNetLocalAuthentication()` after CORS and before +scheme. `AddSenseNetMsSqlProviders` registers `IUserAuthenticationLock`, which serializes each user +across all replicas of the repository, including hosts that only change passwords. Custom storage +providers must register an equivalent lock in the repository service collection (replace older +`ILocalAuthenticationUserLock` registrations). Missing lock registration fails startup. Call `app.UseSenseNetLocalAuthentication()` after CORS and before `app.UseSenseNetAuthentication()`. Example configuration (replace paths, identifiers and networks with deployment values): @@ -255,12 +257,25 @@ must already be enrolled. Challenges are hashed in persistent storage and can be completed on another repository replica. Consumption, enrollment and password changes are serialized with the repository's -`ILocalAuthenticationUserLock`; the dedicated SQL host uses database-scoped sp_getapplock +`IUserAuthenticationLock`; the SQL provider uses database-scoped sp_getapplock on a dedicated unpooled connection, released by disconnect. It does not use the legacy ExclusiveLocks provider/table. Custom hosts must register an equivalent cross-replica lock. Each challenge succeeds once. Account/IP throttling applies to MFA attempts as well. Existing single-request username/password/twoFactorCode clients remain compatible. +Every existing-user password change through `User.SaveAsync`, including the `ChangePassword` +action, direct field saves and hash replacement, revokes all local sessions and pending MFA/reset +challenges. Permanent MFA secrets, enrollment and API keys are preserved. New-user creation, +metadata saves and password hash algorithm migration do not revoke sessions. Credential checks +read the committed user data directly from storage while holding the same per-user lock. + +Revocation precedes password persistence under that lock. If token storage fails, the password +change fails; if the subsequent user save fails, sessions may already have been revoked and the +user must sign in again with the unchanged password. Lock contention fails the operation; retry +the complete operation with fresh data. An already-running request may finish, but any access token +returned by a racing refresh refers to the deleted session and cannot authenticate another request. +Deploy this change on every host that can issue local sessions or save user passwords. + ### Manual acceptance checks 1. Open the separate SB-167 Admin UI with `?repoUrl=https%3A%2F%2Frepository.example`. @@ -329,7 +344,7 @@ must additionally run the lifecycle against its disposable database. PostgreSQL provider sources are not on the develop base used for this feature. Admin UI and .NET client integration are separate parts of SB-167 and are not provided by this package. -Set SB167_TEST_SQL to a disposable SQL Server connection string to run the SQL lock test -and all auth flows with the dedicated host's real application lock implementation. -Without it, auth tests use an in-process test lock and the SQL-specific test is skipped. +Set SB167_TEST_SQL to a disposable SQL Server connection string to run the SQL lock integration test. +Authentication flow tests use the in-memory repository and its per-user lock, shared by the test hosts. +Without the environment variable, the SQL-specific test is skipped. Never point these tests at a shared/live repository database. diff --git a/src/Authentication.Local/ILocalAuthenticationUserLock.cs b/src/Authentication.Local/ILocalAuthenticationUserLock.cs index 6eb61b991..d40441589 100644 --- a/src/Authentication.Local/ILocalAuthenticationUserLock.cs +++ b/src/Authentication.Local/ILocalAuthenticationUserLock.cs @@ -1,8 +1,4 @@ namespace SenseNet.Authentication.Local; -/// Exclusive per-user lock shared by every host issuing sessions for a repository. -public interface ILocalAuthenticationUserLock -{ - /// A held lock, or null when another operation owns it. Dispose releases it. - Task TryAcquireAsync(int userId, CancellationToken cancellationToken); -} +/// Compatibility alias. Register IUserAuthenticationLock with the repository services instead. +public interface ILocalAuthenticationUserLock : SenseNet.ContentRepository.Storage.Security.IUserAuthenticationLock; diff --git a/src/Authentication.Local/LocalAuthenticationExtensions.cs b/src/Authentication.Local/LocalAuthenticationExtensions.cs index 32e7b6758..8599cfc10 100644 --- a/src/Authentication.Local/LocalAuthenticationExtensions.cs +++ b/src/Authentication.Local/LocalAuthenticationExtensions.cs @@ -90,8 +90,8 @@ public static IApplicationBuilder UseSenseNetLocalAuthentication(this IApplicati var options = app.ApplicationServices.GetRequiredService(); if (options.Mode != LocalAuthenticationMode.Disabled) { - if (app.ApplicationServices.GetService() == null) - throw new InvalidOperationException("Register a repository-wide ILocalAuthenticationUserLock before enabling local authentication."); + if (app.ApplicationServices.GetService() == null) + throw new InvalidOperationException("Register a repository-wide IUserAuthenticationLock before enabling local authentication."); var repositoryAuth = app.ApplicationServices.GetService>()?.Value; if (repositoryAuth?.AddJwtCookie == true) diff --git a/src/Authentication.Local/LocalAuthenticationFlows.cs b/src/Authentication.Local/LocalAuthenticationFlows.cs index e3792ff57..0611dd798 100644 --- a/src/Authentication.Local/LocalAuthenticationFlows.cs +++ b/src/Authentication.Local/LocalAuthenticationFlows.cs @@ -22,11 +22,10 @@ public sealed record LocalMfaChallenge(string ChallengeToken, int ExpiresIn, /// Interactive login and recovery; all usable tokens remain outside persistent storage. internal sealed class LocalAuthenticationFlows(LocalAuthenticationOptions options, LocalAuthenticationPolicy policy, IAccessTokenDataProvider storage, ILocalAuthenticationUsers users, LocalAuthenticationSessions sessions, - ILogger logger, ILocalAuthenticationUserLock locks) + ILogger logger) { private const string MfaFeature = "local-auth-mfa"; private const string ResetFeature = "local-auth-reset"; - private const string SessionFeature = "local-auth-session"; private static readonly TimeSpan MfaLifetime = TimeSpan.FromMinutes(5); private bool Listed(User? user) => user is { Enabled: true } && user.Id > 0 && user.Id != User.Visitor.Id && @@ -47,12 +46,11 @@ private bool RequiresMfa(User user) => options.RequireMultiFactor || if (!challenge || !string.IsNullOrEmpty(code)) { var id = await users.ValidateAsync(context, username, password, code).ConfigureAwait(false); - return id.HasValue ? await sessions.CreateAsync(id.Value, cancel).ConfigureAwait(false) : null; + return id == candidate.Id ? await sessions.CreateAsync(id.Value, cancel).ConfigureAwait(false) : null; } if (!policy.TryAttempt(context, "id:" + candidate.Id)) return null; - var credentials = IdentityOperations.ValidateCredentials(null!, context, username, password); - var user = await Node.LoadAsync(credentials.Id, cancel).ConfigureAwait(false); - if (!Listed(user) || (RequiresMfa(user) && !user.EffectiveMultiFactorEnabled)) return null; + var user = await RepositoryLocalUsers.ValidatePasswordAsync(username, password, cancel).ConfigureAwait(false); + if (user.Id != candidate.Id || !Listed(user) || (RequiresMfa(user) && !user.EffectiveMultiFactorEnabled)) return null; if (!user.EffectiveMultiFactorEnabled) return await sessions.CreateAsync(user.Id, cancel).ConfigureAwait(false); // Emergency Secondary-mode access still requires prior MFA enrollment. @@ -81,7 +79,7 @@ private bool RequiresMfa(User user) => options.RequireMultiFactor || record = await Load(token, MfaFeature, cancel).ConfigureAwait(false); if (record == null || !policy.TryAttempt(context, "id:" + record.UserId)) return null; using var account = new SystemAccount(); - var user = await Node.LoadAsync(record.UserId, cancel).ConfigureAwait(false); + var user = await User.LoadForAuthenticationAsync(record.UserId, cancel).ConfigureAwait(false); if (!Listed(user) || !user.EffectiveMultiFactorEnabled) return null; try { @@ -151,13 +149,12 @@ public async Task ResetPasswordAsync(HttpContext context, string token, st record = await Load(token, ResetFeature, cancel).ConfigureAwait(false); if (record == null) return null; using var account = new SystemAccount(); - var user = await Node.LoadAsync(record.UserId, cancel).ConfigureAwait(false); + var user = await User.LoadForAuthenticationAsync(record.UserId, cancel).ConfigureAwait(false); if (!Listed(user)) return null; // Consume before changing the password; failures require a new email, never replay. await storage.DeleteAccessTokenAsync(record.Value, cancel).ConfigureAwait(false); try { - await DeleteFeatureTokens(user.Id, cancel, ResetFeature, MfaFeature, SessionFeature).ConfigureAwait(false); await IdentityOperations.ChangePassword(Content.Create(user), context, password).ConfigureAwait(false); return new object(); } @@ -199,12 +196,15 @@ private async Task DeleteFeatureTokens(int userId, CancellationToken cancel, par Func> action) where T : class { var original = context.RequestAborted; - using var bounded = CancellationTokenSource.CreateLinkedTokenSource(original); - bounded.CancelAfter(TimeSpan.FromSeconds(30)); - await using var held = await locks.TryAcquireAsync(userId, bounded.Token).ConfigureAwait(false); - if (held == null) return null; - context.RequestAborted = bounded.Token; - try { return await action(bounded.Token).ConfigureAwait(false); } + try + { + return await UserAuthenticationScope.ExecuteAsync(userId, async cancel => + { + context.RequestAborted = cancel; + return await action(cancel).ConfigureAwait(false); + }, original).ConfigureAwait(false); + } + catch (UserAuthenticationLockException) { return null; } finally { context.RequestAborted = original; } } } diff --git a/src/Authentication.Local/RepositoryLocalUsers.cs b/src/Authentication.Local/RepositoryLocalUsers.cs index b0cdbee44..298dbf858 100644 --- a/src/Authentication.Local/RepositoryLocalUsers.cs +++ b/src/Authentication.Local/RepositoryLocalUsers.cs @@ -26,10 +26,9 @@ internal sealed class RepositoryLocalUsers(LocalAuthenticationOptions options, L if (candidate != null && !policy.TryAttempt(context, "id:" + candidate.Id)) return null; } - var result = IdentityOperations.ValidateCredentials(null!, context, username, password); return await SystemAccount.ExecuteAsync(async () => { - var user = await Node.LoadAsync(result.Id, context.RequestAborted).ConfigureAwait(false); + var user = await ValidatePasswordAsync(username, password, context.RequestAborted).ConfigureAwait(false); if (!IsAllowed(user)) return (int?)null; if (RequiresMfa(user) || user.EffectiveMultiFactorEnabled) @@ -51,6 +50,17 @@ public Task IsAllowedAsync(int userId, CancellationToken cancellationToken SystemAccount.ExecuteAsync(async () => IsAllowed(await Node.LoadAsync(userId, cancellationToken).ConfigureAwait(false))); + internal static async Task ValidatePasswordAsync(string username, string password, CancellationToken cancel) + { + var candidate = User.Load(username); + var user = candidate == null ? null : await User.LoadForAuthenticationAsync(candidate.Id, cancel).ConfigureAwait(false); + if (user is not { Enabled: true } || + !string.Equals(candidate!.LoginName, user.LoginName, StringComparison.OrdinalIgnoreCase) || + !user.CheckPasswordMatch(password)) + throw new SenseNetSecurityException("Invalid username or password."); + return user; + } + private bool RequiresMfa(User user) => options.RequireMultiFactor || (options.Mode == LocalAuthenticationMode.Secondary && (user.Id == 1 || user.IsInGroup(Group.Administrators))); diff --git a/src/ContentRepository.InMemory/InMemoryExtensions.cs b/src/ContentRepository.InMemory/InMemoryExtensions.cs index 086e57f0f..64179f47d 100644 --- a/src/ContentRepository.InMemory/InMemoryExtensions.cs +++ b/src/ContentRepository.InMemory/InMemoryExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using SenseNet.Configuration; using SenseNet.ContentRepository; +using SenseNet.ContentRepository.Storage.Security; using SenseNet.ContentRepository.Components; using SenseNet.ContentRepository.InMemory; using SenseNet.ContentRepository.Storage; @@ -93,6 +94,7 @@ public static IServiceCollection AddSenseNetInMemoryProviders(this IServiceColle .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSenseNetBlobStorageMetaDataProvider() diff --git a/src/ContentRepository.InMemory/InMemoryUserAuthenticationLock.cs b/src/ContentRepository.InMemory/InMemoryUserAuthenticationLock.cs new file mode 100644 index 000000000..9da1ec376 --- /dev/null +++ b/src/ContentRepository.InMemory/InMemoryUserAuthenticationLock.cs @@ -0,0 +1,30 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using SenseNet.ContentRepository.Storage.Security; + +namespace SenseNet.ContentRepository.Storage.Data; + +/// Per-user locks for the process-local in-memory repository. +public sealed class InMemoryUserAuthenticationLock : IUserAuthenticationLock +{ + private readonly ConcurrentDictionary _gates = new(); + + public async Task TryAcquireAsync(int userId, CancellationToken cancellationToken) + { + if (userId <= 0) throw new ArgumentOutOfRangeException(nameof(userId)); + var gate = _gates.GetOrAdd(userId, _ => new SemaphoreSlim(1, 1)); + return await gate.WaitAsync(0, cancellationToken).ConfigureAwait(false) ? new Lease(gate) : null; + } + + private sealed class Lease(SemaphoreSlim gate) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref gate, null)?.Release(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/ContentRepository.MsSql/MsSqlExtensions.cs b/src/ContentRepository.MsSql/MsSqlExtensions.cs index 28f78ac20..d5700e46d 100644 --- a/src/ContentRepository.MsSql/MsSqlExtensions.cs +++ b/src/ContentRepository.MsSql/MsSqlExtensions.cs @@ -6,6 +6,7 @@ using SenseNet.ContentRepository.Storage; using SenseNet.ContentRepository.Storage.Data; using SenseNet.ContentRepository.Storage.Data.MsSqlClient; +using SenseNet.ContentRepository.Storage.Security; using SenseNet.Storage.Data.MsSqlClient; using SenseNet.Tools; @@ -26,6 +27,7 @@ public static IServiceCollection AddSenseNetMsSqlProviders(this IServiceCollecti .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSenseNetMsSqlStatisticalDataProvider() .AddDatabaseAuditEventWriter() diff --git a/src/ContentRepository.MsSql/MsSqlUserAuthenticationLock.cs b/src/ContentRepository.MsSql/MsSqlUserAuthenticationLock.cs new file mode 100644 index 000000000..dcd6d7b53 --- /dev/null +++ b/src/ContentRepository.MsSql/MsSqlUserAuthenticationLock.cs @@ -0,0 +1,50 @@ +#nullable enable +using System; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; +using SenseNet.ContentRepository.Storage.Security; +using SenseNet.Configuration; + +namespace SenseNet.ContentRepository.Storage.Data.MsSqlClient; + +/// Database-scoped SQL application locks, independent of the legacy ExclusiveLocks table. +public class MsSqlUserAuthenticationLock(IOptions connectionStrings) + : IUserAuthenticationLock +{ + public async Task TryAcquireAsync(int userId, CancellationToken cancellationToken) + { + if (userId <= 0) throw new ArgumentOutOfRangeException(nameof(userId)); + // A physical disconnect releases the session-owned lock even if an operation fails. + var connectionString = new SqlConnectionStringBuilder(connectionStrings.Value.Repository) + { + Pooling = false, + ConnectTimeout = 10 + }; + var connection = new SqlConnection(connectionString.ConnectionString); + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + using var command = connection.CreateCommand(); + command.CommandTimeout = 10; + command.CommandText = @" +DECLARE @result int; +EXEC @result = sys.sp_getapplock @Resource = @resource, @LockMode = 'Exclusive', + @LockOwner = 'Session', @LockTimeout = 0, @DbPrincipal = 'public'; +SELECT @result;"; + command.Parameters.Add("@resource", SqlDbType.NVarChar, 255).Value = "sensenet-local-auth-user:" + userId; + var result = Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)); + if (result >= 0) return connection; + await connection.DisposeAsync().ConfigureAwait(false); + return null; + } + catch + { + await connection.DisposeAsync().ConfigureAwait(false); + throw; + } + } +} + diff --git a/src/ContentRepository/User.cs b/src/ContentRepository/User.cs index 9beeb9f29..382636b6a 100644 --- a/src/ContentRepository/User.cs +++ b/src/ContentRepository/User.cs @@ -675,8 +675,13 @@ public bool CheckPasswordMatch(string passwordInClearText) // Migration: generating a new hash with the configured provider and salt. this.PasswordHash = PasswordHashProvider.EncodePassword(passwordInClearText, this); - using (new SystemAccount()) - SaveAsync(SavingMode.KeepVersion, CancellationToken.None).GetAwaiter().GetResult(); + _migratingPasswordHash = true; + try + { + using (new SystemAccount()) + SaveAsync(SavingMode.KeepVersion, CancellationToken.None).GetAwaiter().GetResult(); + } + finally { _migratingPasswordHash = false; } return true; } @@ -1003,7 +1008,38 @@ private void SaveCurrentPassword() } + private bool _migratingPasswordHash; + + /// Bypasses node caches so authentication sees committed credential changes on other hosts. + public static async System.Threading.Tasks.Task LoadForAuthenticationAsync(int userId, CancellationToken cancel) + { + return await LoadNodeFromStorageAsync(userId, cancel).ConfigureAwait(false) as User; + } + public override async System.Threading.Tasks.Task SaveAsync(NodeSaveSettings settings, CancellationToken cancel) + { + if (Id > 0 && (_password != null || IsPropertyChanged(nameof(PasswordHash)))) + { + Security.Assert(PermissionType.Save); + await UserAuthenticationScope.ExecuteAsync(Id, async token => + { + if (!_migratingPasswordHash || _password != null) + { + // Revoke before persistence: a revocation failure must prevent a password change. + // Preserve permanent MFA secrets and machine/API credentials. + foreach (var feature in new[] { "local-auth-session", "local-auth-mfa", "local-auth-reset" }) + await AccessTokenVault.DeleteTokensAsync(Id, 0, feature, token).ConfigureAwait(false); + LastLoggedOut = DateTime.UtcNow; + } + await SaveUserAsync(settings, token).ConfigureAwait(false); + return true; + }, cancel).ConfigureAwait(false); + return; + } + await SaveUserAsync(settings, cancel).ConfigureAwait(false); + } + + private async System.Threading.Tasks.Task SaveUserAsync(NodeSaveSettings settings, CancellationToken cancel) { if (_inactivating) { @@ -1032,6 +1068,7 @@ public override async System.Threading.Tasks.Task SaveAsync(NodeSaveSettings set // AD Sync SynchUser(originalId); + _password = null; if (originalId == 0) { diff --git a/src/Storage/Node.cs b/src/Storage/Node.cs index 3d512c65f..7966447ed 100644 --- a/src/Storage/Node.cs +++ b/src/Storage/Node.cs @@ -2645,6 +2645,22 @@ private static Node CreateTargetClass(NodeToken token) return node; } + /// Loads the latest data directly from storage for credential-sensitive operations. + protected static async Task LoadNodeFromStorageAsync(int nodeId, CancellationToken cancellationToken) + { + var provider = Providers.Instance.DataProvider; + var head = await provider.LoadNodeHeadAsync(nodeId, cancellationToken).ConfigureAwait(false); + if (head == null) + return null; + Providers.Instance.SecurityHandler.Assert(head, PermissionType.Open, PermissionType.OpenMinor); + var data = (await provider.LoadNodesAsync(new[] { head.LastMinorVersionId }, cancellationToken) + .ConfigureAwait(false)).FirstOrDefault(); + if (data == null) + return null; + return CreateTargetClass(new NodeToken(head.Id, head.NodeTypeId, head.ContentListId, + head.ContentListTypeId, head.LastMinorVersionId, null) { NodeHead = head, NodeData = data }); + } + /// /// Returns a cached value by the provided name. You can store any instance-related cacheable /// object using the method. diff --git a/src/Storage/Security/IUserAuthenticationLock.cs b/src/Storage/Security/IUserAuthenticationLock.cs new file mode 100644 index 000000000..ed2e0e7ea --- /dev/null +++ b/src/Storage/Security/IUserAuthenticationLock.cs @@ -0,0 +1,12 @@ +#nullable enable +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace SenseNet.ContentRepository.Storage.Security; + +/// Serializes credential changes and local authentication across all repository hosts. +public interface IUserAuthenticationLock +{ + Task TryAcquireAsync(int userId, CancellationToken cancellationToken); +} diff --git a/src/Storage/Security/UserAuthenticationScope.cs b/src/Storage/Security/UserAuthenticationScope.cs new file mode 100644 index 000000000..e5e02d730 --- /dev/null +++ b/src/Storage/Security/UserAuthenticationScope.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SenseNet.Configuration; + +namespace SenseNet.ContentRepository.Storage.Security; + +/// Runs an authentication operation under the repository's per-user lock. +public static class UserAuthenticationScope +{ + private sealed class Scope(int userId, IUserAuthenticationLock provider) + { + public readonly int UserId = userId; + public readonly IUserAuthenticationLock Provider = provider; + public bool Active = true; + } + + private static readonly AsyncLocal Current = new AsyncLocal(); + + /// + /// Nested saves in the same operation reuse its lock. Do not fork parallel operations inside the callback. + /// Contention fails closed; callers may retry the entire operation with fresh credentials/data. + /// + public static async Task ExecuteAsync(int userId, Func> action, + CancellationToken cancellationToken) + { + if (userId <= 0) + throw new ArgumentOutOfRangeException(nameof(userId)); + var provider = Providers.Instance.Services.GetRequiredService(); + var previous = Current.Value; + if (previous is { Active: true } && previous.UserId == userId && previous.Provider == provider) + return await action(cancellationToken).ConfigureAwait(false); + + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + bounded.CancelAfter(TimeSpan.FromSeconds(30)); + await using var held = await provider.TryAcquireAsync(userId, bounded.Token).ConfigureAwait(false); + if (held == null) + throw new UserAuthenticationLockException(); + var scope = new Scope(userId, provider); + Current.Value = scope; + try { return await action(bounded.Token).ConfigureAwait(false); } + finally + { + scope.Active = false; + Current.Value = previous; + } + } +} + +/// The user is already participating in another authentication or password-change operation. +public sealed class UserAuthenticationLockException : InvalidOperationException +{ + public UserAuthenticationLockException() : base("Another authentication operation is in progress. Retry the operation.") { } +} diff --git a/src/Tests/SenseNet.Authentication.Local.Tests/LocalAuthenticationTests.cs b/src/Tests/SenseNet.Authentication.Local.Tests/LocalAuthenticationTests.cs index c9b4ccf87..2818e1142 100644 --- a/src/Tests/SenseNet.Authentication.Local.Tests/LocalAuthenticationTests.cs +++ b/src/Tests/SenseNet.Authentication.Local.Tests/LocalAuthenticationTests.cs @@ -43,13 +43,7 @@ private static TestServer CreateServer(RsaSecurityKey key, .ConfigureServices(s => { s.AddLogging(); - var sql = Environment.GetEnvironmentVariable("SB167_TEST_SQL"); - if (string.IsNullOrEmpty(sql)) - s.AddSingleton(); - else - s.AddSingleton( - new SnWebApplication.Api.Sql.LocalAuth.SqlLocalAuthenticationUserLock( - Microsoft.Extensions.Options.Options.Create(new ConnectionStringOptions { Repository = sql }))); + s.AddSingleton(Providers.Instance.Services.GetRequiredService()); s.AddSingleton(Providers.Instance.Services.GetRequiredService()); services?.Invoke(s); s.AddSenseNetLocalAuthentication(o => { Configure(o, key); configure?.Invoke(o); }); diff --git a/src/Tests/SenseNet.Authentication.Local.Tests/PasswordChangeSessionTests.cs b/src/Tests/SenseNet.Authentication.Local.Tests/PasswordChangeSessionTests.cs new file mode 100644 index 000000000..fbc26cf60 --- /dev/null +++ b/src/Tests/SenseNet.Authentication.Local.Tests/PasswordChangeSessionTests.cs @@ -0,0 +1,309 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Reflection; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SenseNet.Configuration; +using SenseNet.ContentRepository; +using SenseNet.ContentRepository.InMemory; +using SenseNet.ContentRepository.Storage.Data; +using SenseNet.ContentRepository.Storage.Security; +using SenseNet.Services.Core.Operations; +using SenseNet.Extensions.DependencyInjection; +using SenseNet.Storage.Security; +using Task = System.Threading.Tasks.Task; + +namespace SenseNet.Authentication.Local.Tests; + +public partial class LocalAuthenticationTests +{ + private const string ChangedPassword = "changed-password-12345"; + + [DataTestMethod] + [DataRow("action")] + [DataRow("field")] + [DataRow("hash")] + public Task PasswordChange_RevokesSessionsOnAllHostsAndPreservesOtherCredentials(string route) => Test(async () => + { + using var rsa = RSA.Create(2048); + var key = new RsaSecurityKey(rsa) { KeyId = "test" }; + using var first = CreateServer(key); + using var second = CreateServer(key); + using var client = Client(first); + using var otherClient = Client(second); + var session = (await (await Login(client)).Content.ReadFromJsonAsync())!; + var otherSession = (await (await Login(otherClient)).Content.ReadFromJsonAsync())!; + var storage = Providers.Instance.Services.GetRequiredService(); + var user = User.Administrator; + var mfaKey = user.TwoFactorKey; + foreach (var feature in new[] { "local-auth-mfa", "local-auth-reset", "apikey", "unrelated-feature" }) + await SeedToken(storage, user.Id, feature, feature); + var preserved = (await storage.LoadAccessTokensAsync(user.Id, CancellationToken.None)) + .Where(t => !t.Feature.StartsWith("local-auth-")).Select(t => t.Value).ToArray(); + await SeedToken(storage, User.Visitor.Id, "local-auth-session", "another-user-session"); + + if (route == "action") + await IdentityOperations.ChangePassword(Content.Create(user), new DefaultHttpContext(), ChangedPassword); + else + { + if (route == "hash") user.PasswordHash = PasswordHashProvider.EncodePassword(ChangedPassword, user); + else user.Password = ChangedPassword; + await user.SaveAsync(SavingMode.KeepVersion, CancellationToken.None); + } + + foreach (var old in new[] { session, otherSession }) + { + otherClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", old.AccessToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await otherClient.GetAsync("/protected")).StatusCode); + otherClient.DefaultRequestHeaders.Authorization = null; + Assert.AreEqual(HttpStatusCode.Unauthorized, (await otherClient.PostAsJsonAsync( + "/authentication/local/refresh", new { old.RefreshToken })).StatusCode); + } + var remaining = await storage.LoadAccessTokensAsync(user.Id, CancellationToken.None); + Assert.IsFalse(remaining.Any(t => t.Feature.StartsWith("local-auth-"))); + CollectionAssert.AreEquivalent(preserved, remaining.Select(t => t.Value).ToArray()); + Assert.AreEqual(mfaKey, (await User.LoadForAuthenticationAsync(user.Id, CancellationToken.None)).TwoFactorKey); + Assert.IsTrue((await storage.LoadAccessTokensAsync(User.Visitor.Id, CancellationToken.None)) + .Any(t => t.Value == "another-user-session")); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Login(otherClient)).StatusCode); + var login = await Login(otherClient, ChangedPassword); + Assert.AreEqual(HttpStatusCode.OK, login.StatusCode); + var newSession = (await login.Content.ReadFromJsonAsync())!; + otherClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", newSession.AccessToken); + Assert.AreEqual(HttpStatusCode.OK, (await otherClient.GetAsync("/protected")).StatusCode); + + // Reusing the saved User instance for metadata must not reapply its old password assignment. + user.Email = "updated@example.test"; + await user.SaveAsync(SavingMode.KeepVersion, CancellationToken.None); + Assert.AreEqual(HttpStatusCode.OK, (await otherClient.GetAsync("/protected")).StatusCode); + }); + + [TestMethod] + public Task CreatingUser_DoesNotRevokeOtherUsersSessions() => Test(async () => + { + var storage = Providers.Instance.Services.GetRequiredService(); + await SeedToken(storage, 1, "local-auth-session", "existing-session"); + var user = new User(User.Administrator.Parent) { Name = "new-local-user", Password = ChangedPassword }; + await user.SaveAsync(CancellationToken.None); + Assert.IsTrue((await storage.LoadAccessTokensAsync(1, CancellationToken.None)).Any(t => t.Value == "existing-session")); + }); + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public Task HashMigration_PreservesSessionsOnlyWithoutAPendingPasswordChange(bool changePassword) => Test2( + s => s.AddPasswordHashProviderForMigration(), _ => { }, async () => + { + var user = User.Administrator; + user.PasswordHash = new Sha256PasswordHashProvider().Encode("admin", user); + await user.SaveAsync(CancellationToken.None); + var storage = Providers.Instance.Services.GetRequiredService(); + await SeedToken(storage, user.Id, "local-auth-session", "migration-session"); + var setting = typeof(SenseNet.Configuration.Security).GetProperty("EnablePasswordHashMigration")!; + var original = setting.GetValue(null); + setting.SetValue(null, true); + try + { + if (changePassword) user.Password = ChangedPassword; + Assert.IsTrue(await UserAuthenticationScope.ExecuteAsync(user.Id, + _ => Task.FromResult(user.CheckPasswordMatch("admin")), CancellationToken.None)); + var stored = await User.LoadForAuthenticationAsync(user.Id, CancellationToken.None); + Assert.IsTrue(PasswordHashProvider.CheckPassword(changePassword ? ChangedPassword : "admin", stored.PasswordHash, stored)); + var tokens = await storage.LoadAccessTokensAsync(user.Id, CancellationToken.None); + Assert.AreEqual(!changePassword, tokens.Any(t => t.Value == "migration-session")); + } + finally { setting.SetValue(null, original); } + }); + + [TestMethod] + public Task PasswordChange_CannotPassAnInFlightLogin() => Test(async () => + { + using var rsa = RSA.Create(2048); + var key = new RsaSecurityKey(rsa) { KeyId = "test" }; + var gate = new PausedUsers(pauseValidation: true); + using var first = CreateServer(key, services: s => s.AddSingleton(gate)); + using var second = CreateServer(key); + using var client = Client(first); + using var otherClient = Client(second); + var login = Login(client); + try + { + await gate.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var user = User.Administrator; + user.Password = ChangedPassword; + await Assert.ThrowsExceptionAsync(() => user.SaveAsync(CancellationToken.None)); + Assert.IsTrue((await User.LoadForAuthenticationAsync(1, CancellationToken.None)).CheckPasswordMatch("admin")); + } + finally { gate.Release.TrySetResult(); } + var session = (await (await login).Content.ReadFromJsonAsync())!; + var fresh = await User.LoadForAuthenticationAsync(1, CancellationToken.None); + fresh.Password = ChangedPassword; + await fresh.SaveAsync(CancellationToken.None); + otherClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await otherClient.GetAsync("/protected")).StatusCode); + otherClient.DefaultRequestHeaders.Authorization = null; + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Login(otherClient)).StatusCode); + Assert.AreEqual(HttpStatusCode.OK, (await Login(otherClient, ChangedPassword)).StatusCode); + }); + + [TestMethod] + public Task ConcurrentRefresh_CannotResurrectARevokedSession() => Test(async () => + { + using var rsa = RSA.Create(2048); + var key = new RsaSecurityKey(rsa) { KeyId = "test" }; + using var first = CreateServer(key); + using var client = Client(first); + var session = (await (await Login(client)).Content.ReadFromJsonAsync())!; + var gate = new PausedUsers(pauseValidation: false); + using var second = CreateServer(key, services: s => s.AddSingleton(gate)); + using var otherClient = Client(second); + var refresh = otherClient.PostAsJsonAsync("/authentication/local/refresh", new { session.RefreshToken }); + try + { + await gate.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var user = User.Administrator; + user.Password = ChangedPassword; + await user.SaveAsync(CancellationToken.None); + } + finally { gate.Release.TrySetResult(); } + // A refresh that read the session before revocation may finish, but its JWT must be unusable. + var response = await refresh; + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var racedSession = (await response.Content.ReadFromJsonAsync())!; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", racedSession.AccessToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await client.GetAsync("/protected")).StatusCode); + client.DefaultRequestHeaders.Authorization = null; + Assert.AreEqual(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync( + "/authentication/local/refresh", new { racedSession.RefreshToken })).StatusCode); + }); + + [TestMethod] + public Task PasswordChange_BlocksLoginAndMfaUntilRevocationCompletes() => Test2(s => + { + s.AddSingleton(_ => FailingTokenProvider.Create()); + s.AddMultiFactorAuthenticationProvider(); + }, _ => { }, async () => + { + var user = User.Administrator; + user.Email = "admin@example.test"; + user.MultiFactorEnabled = true; + await user.SaveAsync(CancellationToken.None); + user.MultiFactorRegistered = true; + await user.SaveAsync(CancellationToken.None); + var secret = user.TwoFactorKey; + using var rsa = RSA.Create(2048); + var mail = new CapturedMail(); + using var server = CreateServer(new RsaSecurityKey(rsa) { KeyId = "test" }, Recovery, services: s => + { + s.AddSingleton(mail); + s.AddSingleton(); + }); + using var client = Client(server); + var begin = await client.PostAsJsonAsync("/authentication/local/login", + new { username = "builtin\\admin", password = "admin", requestMfaChallenge = true }); + Assert.AreEqual(HttpStatusCode.Accepted, begin.StatusCode); + var challenge = (await begin.Content.ReadFromJsonAsync())!; + await client.PostAsJsonAsync("/authentication/local/forgot-password", new { user.Email }); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var provider = (FailingTokenProvider)Providers.Instance.Services.GetRequiredService(); + provider.BeforeRevocation = async () => + { + entered.TrySetResult(); + await release.Task.WaitAsync(TimeSpan.FromSeconds(15)); + }; + user.Password = ChangedPassword; + var save = user.SaveAsync(CancellationToken.None); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Login(client, code: "correct")).StatusCode); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/authentication/local/mfa", + new { challenge.ChallengeToken, twoFactorCode = "correct" })).StatusCode); + } + finally { release.TrySetResult(); } + await save; + Assert.AreEqual(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/authentication/local/mfa", + new { challenge.ChallengeToken, twoFactorCode = "correct" })).StatusCode); + Assert.AreEqual(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/authentication/local/reset-password", + new { token = mail.Token, password = "another-new-password-42" })).StatusCode); + var stored = await User.LoadForAuthenticationAsync(1, CancellationToken.None); + Assert.IsTrue(stored.MultiFactorRegistered); + Assert.AreEqual(secret, stored.TwoFactorKey); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Login(client, ChangedPassword)).StatusCode); + Assert.AreEqual(HttpStatusCode.OK, (await Login(client, ChangedPassword, "correct")).StatusCode); + }); + + [TestMethod] + public Task FailedRevocation_PreventsPasswordPersistence() => Test2( + s => s.AddSingleton(_ => FailingTokenProvider.Create()), _ => { }, async () => + { + var storage = Providers.Instance.Services.GetRequiredService(); + var failing = (FailingTokenProvider)storage; + failing.FailRevocation = true; + var user = User.Administrator; + user.Password = ChangedPassword; + await Assert.ThrowsExceptionAsync(() => user.SaveAsync(CancellationToken.None)); + var stored = await User.LoadForAuthenticationAsync(1, CancellationToken.None); + Assert.IsTrue(stored.CheckPasswordMatch("admin")); + Assert.IsFalse(stored.CheckPasswordMatch(ChangedPassword)); + failing.FailRevocation = false; + await user.SaveAsync(CancellationToken.None); // The failed operation released its lock. + Assert.IsTrue((await User.LoadForAuthenticationAsync(1, CancellationToken.None)).CheckPasswordMatch(ChangedPassword)); + }); + + private static Task SeedToken(IAccessTokenDataProvider storage, int userId, string feature, string value) => + storage.SaveAccessTokenAsync(new AccessToken + { + UserId = userId, Feature = feature, Value = value, + CreationDate = DateTime.UtcNow, ExpirationDate = DateTime.UtcNow.AddHours(1) + }, CancellationToken.None); + + private sealed class PausedUsers(bool pauseValidation) : ILocalAuthenticationUsers + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task ValidateAsync(HttpContext context, string username, string password, string? twoFactorCode) + { + using var account = new SystemAccount(); + var user = await User.LoadForAuthenticationAsync(1, context.RequestAborted); + if (!user.CheckPasswordMatch(password)) return null; + if (pauseValidation) await Pause(context.RequestAborted); + return user.Id; + } + public async Task IsAllowedAsync(int userId, CancellationToken cancellationToken) + { + if (!pauseValidation) await Pause(cancellationToken); + return true; + } + private async Task Pause(CancellationToken cancel) + { + Entered.TrySetResult(); + await Release.Task.WaitAsync(TimeSpan.FromSeconds(15), cancel); + } + } + + public class FailingTokenProvider : DispatchProxy + { + private readonly IAccessTokenDataProvider _inner = new InMemoryAccessTokenDataProvider(); + public bool FailRevocation { get; set; } + public Func? BeforeRevocation { get; set; } + public static IAccessTokenDataProvider Create() => Create(); + protected override object? Invoke(MethodInfo? method, object?[]? args) + { + if (method!.Name == nameof(IAccessTokenDataProvider.DeleteAccessTokensAsync)) + return DeleteAsync(method, args); + return method!.Invoke(_inner, args); + } + private async Task DeleteAsync(MethodInfo method, object?[]? args) + { + if (FailRevocation) throw new InvalidOperationException("Simulated token storage failure."); + if (BeforeRevocation != null) await BeforeRevocation(); + await (Task)method.Invoke(_inner, args)!; + } + } +} diff --git a/src/Tests/SenseNet.Authentication.Local.Tests/SenseNet.Authentication.Local.Tests.csproj b/src/Tests/SenseNet.Authentication.Local.Tests/SenseNet.Authentication.Local.Tests.csproj index 1f7f3ada9..6fc0ee0e0 100644 --- a/src/Tests/SenseNet.Authentication.Local.Tests/SenseNet.Authentication.Local.Tests.csproj +++ b/src/Tests/SenseNet.Authentication.Local.Tests/SenseNet.Authentication.Local.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Tests/SenseNet.Authentication.Local.Tests/SqlUserLockTests.cs b/src/Tests/SenseNet.Authentication.Local.Tests/SqlUserLockTests.cs index 8f3f30d63..91822d786 100644 --- a/src/Tests/SenseNet.Authentication.Local.Tests/SqlUserLockTests.cs +++ b/src/Tests/SenseNet.Authentication.Local.Tests/SqlUserLockTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using SenseNet.Configuration; @@ -6,21 +5,6 @@ namespace SenseNet.Authentication.Local.Tests; -// A deterministic test-only equivalent shared by all test hosts in one test process. -internal sealed class TestUserLock : ILocalAuthenticationUserLock -{ - private static readonly ConcurrentDictionary Gates = new(); - public async Task TryAcquireAsync(int userId, CancellationToken cancellationToken) - { - var gate = Gates.GetOrAdd(userId, _ => new SemaphoreSlim(1, 1)); - return await gate.WaitAsync(0, cancellationToken) ? new Lease(gate) : null; - } - private sealed class Lease(SemaphoreSlim gate) : IAsyncDisposable - { - public ValueTask DisposeAsync() { gate.Release(); return ValueTask.CompletedTask; } - } -} - [TestClass] public class SqlUserLockTests { diff --git a/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/SqlLocalAuthenticationUserLock.cs b/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/SqlLocalAuthenticationUserLock.cs index 30333be3a..c20000032 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/SqlLocalAuthenticationUserLock.cs +++ b/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/SqlLocalAuthenticationUserLock.cs @@ -1,48 +1,10 @@ -#nullable enable -using System; -using System.Data; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; using SenseNet.Authentication.Local; using SenseNet.Configuration; +using SenseNet.ContentRepository.Storage.Data.MsSqlClient; namespace SnWebApplication.Api.Sql.LocalAuth; -/// Database-scoped SQL application locks, independent of the legacy ExclusiveLocks table. +/// Compatibility wrapper; AddSenseNetMsSqlProviders registers the repository-wide lock. public sealed class SqlLocalAuthenticationUserLock(IOptions connectionStrings) - : ILocalAuthenticationUserLock -{ - public async Task TryAcquireAsync(int userId, CancellationToken cancellationToken) - { - // A physical disconnect releases the session-owned lock even if an operation fails. - var connectionString = new SqlConnectionStringBuilder(connectionStrings.Value.Repository) - { - Pooling = false, - ConnectTimeout = 10 - }; - var connection = new SqlConnection(connectionString.ConnectionString); - try - { - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - using var command = connection.CreateCommand(); - command.CommandTimeout = 10; - command.CommandText = @" -DECLARE @result int; -EXEC @result = sys.sp_getapplock @Resource = @resource, @LockMode = 'Exclusive', - @LockOwner = 'Session', @LockTimeout = 0, @DbPrincipal = 'public'; -SELECT @result;"; - command.Parameters.Add("@resource", SqlDbType.NVarChar, 255).Value = "sensenet-local-auth-user:" + userId; - var result = Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)); - if (result >= 0) return connection; - await connection.DisposeAsync().ConfigureAwait(false); - return null; - } - catch - { - await connection.DisposeAsync().ConfigureAwait(false); - throw; - } - } -} + : MsSqlUserAuthenticationLock(connectionStrings), ILocalAuthenticationUserLock; diff --git a/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/Startup.cs b/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/Startup.cs index 9300a0834..b360ecee3 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/Startup.cs +++ b/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/Startup.cs @@ -78,7 +78,6 @@ public void ConfigureServices(IServiceCollection services) }); } - services.AddSingleton(); services.AddSenseNetLocalAuthentication(Configuration); // [sensenet]: Set options for ApiKeys