diff --git a/src/Identity/Extensions.Core/src/LockoutOptions.cs b/src/Identity/Extensions.Core/src/LockoutOptions.cs index def4e3605a1c..82173d94bd54 100644 --- a/src/Identity/Extensions.Core/src/LockoutOptions.cs +++ b/src/Identity/Extensions.Core/src/LockoutOptions.cs @@ -32,4 +32,10 @@ public class LockoutOptions /// /// The a user is locked out for when a lockout occurs. public TimeSpan DefaultLockoutTimeSpan { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Specifies whether the lockout should be permanent. + /// If true, the user is locked out. + /// + public bool PermanentLockout { get; set; } } diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 58c861652420..a3ea723ee494 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? displayName) -> void Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? providerDisplayName) -> void +Microsoft.AspNetCore.Identity.LockoutOptions.PermanentLockout.get -> bool +Microsoft.AspNetCore.Identity.LockoutOptions.PermanentLockout.set -> void diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index cca2005d10d0..9898a1fa0ba3 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -1818,15 +1818,30 @@ public virtual async Task AccessFailedAsync(TUser user) var store = GetUserLockoutStore(); ArgumentNullThrowHelper.ThrowIfNull(user); - // If this puts the user over the threshold for lockout, lock them out and reset the access failed count + // If PermanentLockout is enabled, lock the user indefinitely + if (Options.Lockout.PermanentLockout) + { + Logger.LogDebug(LoggerEventIds.UserLockedOut, "User is permanently locked out."); + await store.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue, CancellationToken).ConfigureAwait(false); + return await UpdateUserAsync(user).ConfigureAwait(false); + } + + // Increment access failed count var count = await store.IncrementAccessFailedCountAsync(user, CancellationToken).ConfigureAwait(false); if (count < Options.Lockout.MaxFailedAccessAttempts) { return await UpdateUserAsync(user).ConfigureAwait(false); } + Logger.LogDebug(LoggerEventIds.UserLockedOut, "User is locked out."); - await store.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.Add(Options.Lockout.DefaultLockoutTimeSpan), - CancellationToken).ConfigureAwait(false); + + // Set the lockout time based on configuration. + var now = DateTimeOffset.UtcNow; + DateTimeOffset lockoutEnd = Options.Lockout.DefaultLockoutTimeSpan == TimeSpan.MaxValue + ? DateTimeOffset.MaxValue + : now.Add(Options.Lockout.DefaultLockoutTimeSpan); + + await store.SetLockoutEndDateAsync(user, lockoutEnd, CancellationToken).ConfigureAwait(false); await store.ResetAccessFailedCountAsync(user, CancellationToken).ConfigureAwait(false); return await UpdateUserAsync(user).ConfigureAwait(false); } diff --git a/src/Identity/test/Identity.Test/UserManagerTest.cs b/src/Identity/test/Identity.Test/UserManagerTest.cs index 04f9e2afa476..eb5f12a6401b 100644 --- a/src/Identity/test/Identity.Test/UserManagerTest.cs +++ b/src/Identity/test/Identity.Test/UserManagerTest.cs @@ -1012,6 +1012,42 @@ public async Task ResetTokenCallNoopForTokenValueZero() IdentityResultAssert.IsSuccess(await manager.ResetAccessFailedCountAsync(user)); } + [Fact] + public async Task AccessFailedAsyncIncrementsAccessFailedCount() + { + // Arrange + var user = new PocoUser() { UserName = "testuser" }; + var store = new Mock>(); + int failedCount = 1; + + store.Setup(x => x.SupportsUserLockout).Returns(true); + + store.Setup(x => x.GetAccessFailedCountAsync(user, It.IsAny())) + .ReturnsAsync(() => failedCount); + + store.Setup(x => x.IncrementAccessFailedCountAsync(user, It.IsAny())) + .Callback(() => failedCount++) + .ReturnsAsync(() => failedCount); + + store.Setup(x => x.UpdateAsync(user, It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + var manager = MockHelpers.TestUserManager(store.Object); + manager?.Options?.Lockout?.PermanentLockout = false; + + // Act + var result = await manager.AccessFailedAsync(user); + + // Assert + Assert.NotNull(result); + Assert.True(result.Succeeded, "AccessFailedAsync should return success."); + + store.Verify(x => x.IncrementAccessFailedCountAsync(user, It.IsAny()), Times.Once); + + var newFailedCount = await manager.GetAccessFailedCountAsync(user); + Assert.Equal(2, newFailedCount); + } + [Fact] public async Task ManagerPublicNullChecks() {