Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions docs/local-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
8 changes: 2 additions & 6 deletions src/Authentication.Local/ILocalAuthenticationUserLock.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
namespace SenseNet.Authentication.Local;

/// <summary>Exclusive per-user lock shared by every host issuing sessions for a repository.</summary>
public interface ILocalAuthenticationUserLock
{
/// <returns>A held lock, or null when another operation owns it. Dispose releases it.</returns>
Task<IAsyncDisposable?> TryAcquireAsync(int userId, CancellationToken cancellationToken);
}
/// <summary>Compatibility alias. Register IUserAuthenticationLock with the repository services instead.</summary>
public interface ILocalAuthenticationUserLock : SenseNet.ContentRepository.Storage.Security.IUserAuthenticationLock;
4 changes: 2 additions & 2 deletions src/Authentication.Local/LocalAuthenticationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ public static IApplicationBuilder UseSenseNetLocalAuthentication(this IApplicati
var options = app.ApplicationServices.GetRequiredService<LocalAuthenticationOptions>();
if (options.Mode != LocalAuthenticationMode.Disabled)
{
if (app.ApplicationServices.GetService<ILocalAuthenticationUserLock>() == null)
throw new InvalidOperationException("Register a repository-wide ILocalAuthenticationUserLock before enabling local authentication.");
if (app.ApplicationServices.GetService<SenseNet.ContentRepository.Storage.Security.IUserAuthenticationLock>() == null)
throw new InvalidOperationException("Register a repository-wide IUserAuthenticationLock before enabling local authentication.");
var repositoryAuth = app.ApplicationServices.GetService<Microsoft.Extensions.Options.IOptions<
Services.Core.Authentication.AuthenticationOptions>>()?.Value;
if (repositoryAuth?.AddJwtCookie == true)
Expand Down
30 changes: 15 additions & 15 deletions src/Authentication.Local/LocalAuthenticationFlows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ public sealed record LocalMfaChallenge(string ChallengeToken, int ExpiresIn,
/// <summary>Interactive login and recovery; all usable tokens remain outside persistent storage.</summary>
internal sealed class LocalAuthenticationFlows(LocalAuthenticationOptions options, LocalAuthenticationPolicy policy,
IAccessTokenDataProvider storage, ILocalAuthenticationUsers users, LocalAuthenticationSessions sessions,
ILogger<LocalAuthenticationFlows> logger, ILocalAuthenticationUserLock locks)
ILogger<LocalAuthenticationFlows> 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 &&
Expand All @@ -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<User>(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.
Expand Down Expand Up @@ -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<User>(record.UserId, cancel).ConfigureAwait(false);
var user = await User.LoadForAuthenticationAsync(record.UserId, cancel).ConfigureAwait(false);
if (!Listed(user) || !user.EffectiveMultiFactorEnabled) return null;
try
{
Expand Down Expand Up @@ -151,13 +149,12 @@ public async Task<bool> 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<User>(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();
}
Expand Down Expand Up @@ -199,12 +196,15 @@ private async Task DeleteFeatureTokens(int userId, CancellationToken cancel, par
Func<CancellationToken, Task<T?>> 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; }
}
}
14 changes: 12 additions & 2 deletions src/Authentication.Local/RepositoryLocalUsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>(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)
Expand All @@ -51,6 +50,17 @@ public Task<bool> IsAllowedAsync(int userId, CancellationToken cancellationToken
SystemAccount.ExecuteAsync(async () =>
IsAllowed(await Node.LoadAsync<User>(userId, cancellationToken).ConfigureAwait(false)));

internal static async Task<User> 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)));
Expand Down
2 changes: 2 additions & 0 deletions src/ContentRepository.InMemory/InMemoryExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +94,7 @@ public static IServiceCollection AddSenseNetInMemoryProviders(this IServiceColle
.AddSingleton<IBlobProvider, InMemoryBlobProvider>()
.AddSingleton<IBlobProviderSelector, InMemoryBlobProviderSelector>()
.AddSingleton<IAccessTokenDataProvider, InMemoryAccessTokenDataProvider>()
.AddSingleton<IUserAuthenticationLock, InMemoryUserAuthenticationLock>()
.AddSingleton<ElevatedModificationVisibilityRule>()
.AddSingleton<IPackagingDataProvider, InMemoryPackageStorageProvider>()
.AddSenseNetBlobStorageMetaDataProvider<InMemoryBlobStorageMetaDataProvider>()
Expand Down
30 changes: 30 additions & 0 deletions src/ContentRepository.InMemory/InMemoryUserAuthenticationLock.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Per-user locks for the process-local in-memory repository.</summary>
public sealed class InMemoryUserAuthenticationLock : IUserAuthenticationLock
{
private readonly ConcurrentDictionary<int, SemaphoreSlim> _gates = new();

public async Task<IAsyncDisposable?> 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;
}
}
}
2 changes: 2 additions & 0 deletions src/ContentRepository.MsSql/MsSqlExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -26,6 +27,7 @@ public static IServiceCollection AddSenseNetMsSqlProviders(this IServiceCollecti
.AddSingleton<ISharedLockDataProvider, MsSqlSharedLockDataProvider>()
.AddSingleton<IExclusiveLockDataProvider, MsSqlExclusiveLockDataProvider>()
.AddSingleton<IAccessTokenDataProvider, MsSqlAccessTokenDataProvider>()
.AddSingleton<IUserAuthenticationLock, MsSqlUserAuthenticationLock>()
.AddSingleton<IPackagingDataProvider, MsSqlPackagingDataProvider>()
.AddSenseNetMsSqlStatisticalDataProvider()
.AddDatabaseAuditEventWriter()
Expand Down
50 changes: 50 additions & 0 deletions src/ContentRepository.MsSql/MsSqlUserAuthenticationLock.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Database-scoped SQL application locks, independent of the legacy ExclusiveLocks table.</summary>
public class MsSqlUserAuthenticationLock(IOptions<ConnectionStringOptions> connectionStrings)
: IUserAuthenticationLock
{
public async Task<IAsyncDisposable?> 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;
}
}
}

Loading
Loading