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()
{