Skip to content
Open
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
21 changes: 15 additions & 6 deletions src/SentenceStudio.Api/Auth/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,25 @@ private static async Task<IResult> 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();
}
Expand Down
19 changes: 17 additions & 2 deletions src/SentenceStudio.WebApp/Auth/ServerAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,27 @@ public ServerAuthService(
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();

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");
Expand Down
57 changes: 57 additions & 0 deletions tests/SentenceStudio.Api.Tests/IdentityAuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserManager<ApplicationUser>>();
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)
Expand Down
Loading