diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs index 6629485..c17cc6e 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -40,8 +40,18 @@ this IEndpointRouteBuilder endpoints accountGroup.MapGet( "/SignOutAndRedirect", - async ([FromServices] SignInManager signInManager) => + async (HttpContext context, [FromServices] SignInManager signInManager) => { + // This GET sign-out exists because an interactive Blazor circuit cannot + // write the sign-out cookie itself (the DeletePersonalData flow force- + // navigates here). Reject cross-site requests so a third-party page cannot + // force a sign-out via /link/prefetch (logout CSRF, #A-1). A same- + // origin top-level navigation reports Sec-Fetch-Site: same-origin; a + // cross-site request reports cross-site. Fail closed without signing out. + var site = context.Request.Headers["Sec-Fetch-Site"].FirstOrDefault(); + if (string.Equals(site, "cross-site", StringComparison.OrdinalIgnoreCase)) + return TypedResults.LocalRedirect("~/"); + await signInManager.SignOutAsync(); return TypedResults.LocalRedirect("~/"); } diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/LoginForm.razor b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/LoginForm.razor index 4a9f12a..6f8b5c0 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/LoginForm.razor +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/LoginForm.razor @@ -1,5 +1,6 @@ @inject UserManager UserManager @inject SignInManager SignInManager +@inject IPasswordHasher PasswordHasher @inject ILogger Logger @inject NavigationManager NavigationManager @inject NotificationService NotificationService @@ -57,7 +58,18 @@ { var user = await UserManager.FindByEmailAsync(model.Email); - if (user == null || !(await SignInManager.CanSignInAsync(user))) + if (user is null) + { + // Consume comparable time to a real password verification so a non-existent + // account is not distinguishable from a wrong password by response latency + // (user enumeration, CWE-208). The uniform message already hides it in the + // response body; this closes the timing side channel. + _ = PasswordHasher.HashPassword(new User(), model.Password); + NotificationService.Notify(NotificationSeverity.Error, "LoginError", "Invalid login attempt."); + return; + } + + if (!await SignInManager.CanSignInAsync(user)) { NotificationService.Notify(NotificationSeverity.Error, "LoginError", "Invalid login attempt."); return; diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/LoginForm.Tests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/LoginForm.Tests.cs index 8b0396b..419357f 100644 --- a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/LoginForm.Tests.cs +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/LoginForm.Tests.cs @@ -56,6 +56,7 @@ private static SignInManager BuildSignInManager(UserManager userMana Services.AddSingleton(new NotificationService()); Services.AddSingleton(Substitute.For>()); Services.AddSingleton(new LoginTokenProtector(DataProtectionProvider.Create("Tests"))); + Services.AddSingleton>(new PasswordHasher()); var cut = Render(); return (cut, um); }