From 8be08425923b5b98c819c80f5b0ef8e325e5505f Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Fri, 1 May 2026 10:21:15 -0500 Subject: [PATCH] fix: add lockout guard and move email-confirm to post-auth in Login AuthEndpoints.cs: - Remove pre-authentication email auto-confirm (was a side-effect mutation exposed to unauthenticated callers with any email address) - Check IsLockedOutAsync before attempting password verification; return 429 if the account is locked out - On a failed CheckPasswordAsync, call AccessFailedAsync so failed attempts are recorded and eventually trigger lockout - On success, call ResetAccessFailedCountAsync then re-check returning 401 without confirming email,IsEmailConfirmedAsync so the check is purely read-only and safe for any caller ServerAuthService.cs: - Same lockout guard: IsLockedOutAsync before CheckPasswordAsync, AccessFailedAsync on failure, ResetAccessFailedCountAsync on success IdentityAuthTests.cs: - Add Login_WrongPassword_DoesNotAutoConfirmEmail regression test - Add Login_ExcessiveFailures_TriggersLockout regression test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SentenceStudio.Api/Auth/AuthEndpoints.cs | 21 +++++-- .../Auth/ServerAuthService.cs | 19 ++++++- .../IdentityAuthTests.cs | 57 +++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/SentenceStudio.Api/Auth/AuthEndpoints.cs b/src/SentenceStudio.Api/Auth/AuthEndpoints.cs index 2e756fb0..71a3971e 100644 --- a/src/SentenceStudio.Api/Auth/AuthEndpoints.cs +++ b/src/SentenceStudio.Api/Auth/AuthEndpoints.cs @@ -132,16 +132,25 @@ private static async Task Login( return Results.Unauthorized(); } - if (!await userManager.IsEmailConfirmedAsync(user)) + // Enforce account lockout before checking the password + if (await userManager.IsLockedOutAsync(user)) { - // Auto-confirm migrated accounts (they had no email confirmation requirement before) - // and dev-mode accounts - var confirmToken = await userManager.GenerateEmailConfirmationTokenAsync(user); - await userManager.ConfirmEmailAsync(user, confirmToken); - logger.LogInformation("Auto-confirmed email for {Email} (migrated or unconfirmed account)", request.Email); + logger.LogWarning("Login rejected for {Email}: account is locked out", request.Email); + return Results.Problem("Account is temporarily locked. Please try again later.", statusCode: 429); } if (!await userManager.CheckPasswordAsync(user, request.Password)) + { + await userManager.AccessFailedAsync(user); + return Results.Unauthorized(); + } + + // Password is correct — reset the failure counter + await userManager.ResetAccessFailedCountAsync(user); + + // Email confirmation check moved here (after password verification) so that a wrong-password + // request cannot auto-confirm an account as a side-effect + if (!await userManager.IsEmailConfirmedAsync(user)) { return Results.Unauthorized(); } diff --git a/src/SentenceStudio.WebApp/Auth/ServerAuthService.cs b/src/SentenceStudio.WebApp/Auth/ServerAuthService.cs index c89f5302..8d99c02c 100644 --- a/src/SentenceStudio.WebApp/Auth/ServerAuthService.cs +++ b/src/SentenceStudio.WebApp/Auth/ServerAuthService.cs @@ -101,12 +101,27 @@ public ServerAuthService( var userManager = scope.ServiceProvider.GetRequiredService>(); var user = await userManager.FindByEmailAsync(email); - if (user is null || !await userManager.CheckPasswordAsync(user, password)) + if (user is null) { - _logger.LogWarning("Sign-in failed for {Email}", email); + _logger.LogWarning("Sign-in failed for {Email}: user not found", email); return null; } + if (await userManager.IsLockedOutAsync(user)) + { + _logger.LogWarning("Sign-in rejected for {Email}: account is locked out", email); + return null; + } + + if (!await userManager.CheckPasswordAsync(user, password)) + { + await userManager.AccessFailedAsync(user); + _logger.LogWarning("Sign-in failed for {Email}: wrong password", email); + return null; + } + + await userManager.ResetAccessFailedCountAsync(user); + // Generate a one-time auto-sign-in token var token = await userManager.GenerateUserTokenAsync( user, TokenOptions.DefaultProvider, "AutoSignIn"); diff --git a/tests/SentenceStudio.Api.Tests/IdentityAuthTests.cs b/tests/SentenceStudio.Api.Tests/IdentityAuthTests.cs index d3c6dbb7..890b30ed 100644 --- a/tests/SentenceStudio.Api.Tests/IdentityAuthTests.cs +++ b/tests/SentenceStudio.Api.Tests/IdentityAuthTests.cs @@ -153,6 +153,63 @@ public async Task RefreshToken_ReturnsNewTokens() "refresh token should be rotated"); } + [Fact] + public async Task Login_WrongPassword_DoesNotAutoConfirmEmail() + { + var email = $"no-autoconfirm-{Guid.NewGuid():N}@test.local"; + + // Create user directly with unconfirmed email + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = email, Email = email, DisplayName = "NoAutoConfirm" }; + (await userManager.CreateAsync(user, "Test1234!")).Succeeded.Should().BeTrue(); + + // Attempt login with the WRONG password + var response = await _client.PostAsJsonAsync("/api/auth/login", new + { + Email = email, + Password = "WrongPassword99!" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Email must still be unconfirmed — the failed login must not have auto-confirmed it + var updated = await userManager.FindByEmailAsync(email); + updated!.EmailConfirmed.Should().BeFalse( + "a failed login attempt must not auto-confirm the account's email"); + } + + [Fact] + public async Task Login_ExcessiveFailures_TriggersLockout() + { + var email = $"lockout-{Guid.NewGuid():N}@test.local"; + const string correctPassword = "Test1234!"; + + await RegisterAndConfirmAsync(email, correctPassword); + + // Identity default: MaxFailedAccessAttempts = 5 + for (var i = 0; i < 5; i++) + { + var r = await _client.PostAsJsonAsync("/api/auth/login", new + { + Email = email, + Password = "WrongPassword99!" + }); + r.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + $"attempt {i + 1} with wrong password should return 401"); + } + + // After 5 failures the account should be locked; even the correct password must be rejected + var lockedResponse = await _client.PostAsJsonAsync("/api/auth/login", new + { + Email = email, + Password = correctPassword + }); + + lockedResponse.StatusCode.Should().Be(HttpStatusCode.TooManyRequests, + "account should be locked after exceeding MaxFailedAccessAttempts"); + } + // -- helpers -- private async Task RegisterAndConfirmAsync(string email, string password)