Skip to content

Commit 8d7e3a1

Browse files
feat: add password strength meter with HIBP breach detection (#723)
- Add PwnedPasswordValidator<TUser>: server-side hard block using HIBP k-anonymity range API (SHA-1 prefix, Add-Padding header, fail-open) - Add password-strength.js ES module: zxcvbn-ts client-side strength meter (lazy-loaded on focus, debounced 300ms) + HIBP advisory check on blur - Add _PasswordStrengthMeter.cshtml shared partial with Bootstrap progress bar, feedback text, HIBP breach warning, full a11y (role=progressbar) - Wire partial + script into all 4 password pages: Register, ResetPassword, ChangePassword, SetPassword - Expose UserEmail/UserName on ChangePassword/SetPassword page models for zxcvbn userInputs (populated on GET and failed POST paths) - Add zxcvbn-ts esm.sh CDN entries to both prod/dev ImportMapDefinition - Register named HaveIBeenPwned HttpClient + PwnedPasswordValidator in Program.cs; inherits AddStandardResilienceHandler for retry/circuit-breaker fix: address review findings in password strength meter - PwnedPasswordValidator: compute SHA-1 once, remove Task.Run wrapper, add using to dispose HttpRequestMessage/HttpResponseMessage - Program.cs: cap HIBP HttpClient timeout at 3s to limit latency impact when HIBP is unavailable (fail-open, so short timeout is correct) - password-strength.js: fix HIBP async race condition - guard result application with passwordInput.value check so stale responses from old passwords never show a false breach warning - password-strength.js: use split(/\r?\n/) for robust line ending handling Add PwnedPasswordValidator tests and fix padding entry handling - Fix: Check count > 0 when matching HIBP responses to correctly ignore padded entries (Add-Padding: true sends decoys with count=0) - Add 6 unit tests covering breach detection, safe passwords, fail-open on API errors, padding entry filtering, and null argument guards - Add integration test hitting live HIBP API with known-breached password - Add explicit Moq package reference to test project fix: address Opus 4.7 and GPT-5.4 review findings in password strength meter - Fix JS checkHibp to discard count=0 padded entries per HIBP spec - Remove resilience retry pipeline from HIBP HttpClient (fail-open advisory; retries are counterproductive and worsen rate-limit exposure) - Recompute zxcvbn strength score when user input fields (email/username) change - Add unit test for non-2xx HTTP response (ServiceUnavailable -> fail-open) - Add unit test asserting k-anonymity: only 5-char prefix sent, Add-Padding header present fix: split HIBP response on CRLF explicitly per API spec Previously split on backslash-n relying on TrimEnd() to strip the carriage return. The HIBP Pwned Passwords API specifies CRLF line endings. Now splits on both CRLF and LF to document the protocol expectation and match the JS which already uses the /r?/n/ regex.
1 parent ccc1499 commit 8d7e3a1

15 files changed

Lines changed: 610 additions & 1 deletion

EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<ItemGroup>
1111
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
1212
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
13+
<PackageReference Include="Moq" />
1314
<PackageReference Include="Moq.AutoMock" />
1415
<PackageReference Include="TUnit" />
1516
<PackageReference Include="Newtonsoft.Json" />
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators;
2+
using Microsoft.AspNetCore.Identity;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Moq;
5+
6+
namespace EssentialCSharp.Web.Tests.Integration;
7+
8+
[ClassDataSource<PwnedPasswordServiceProvider>(Shared = SharedType.PerClass)]
9+
public class PwnedPasswordTests(PwnedPasswordServiceProvider serviceProvider)
10+
{
11+
[Test]
12+
public async Task KnownBreachedPassword_IsDetected()
13+
{
14+
IPasswordValidator<IdentityUser> validator = serviceProvider.ServiceProvider
15+
.GetRequiredService<IPasswordValidator<IdentityUser>>();
16+
Mock<IUserStore<IdentityUser>> store = new();
17+
using UserManager<IdentityUser> manager = new(
18+
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
19+
20+
// "password" → SHA-1 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 — always in HIBP (3.8M+ breaches).
21+
IdentityResult result = await validator.ValidateAsync(
22+
manager, new IdentityUser("test"), "password");
23+
24+
await Assert.That(result.Succeeded).IsFalse();
25+
await Assert.That(result.Errors.Select(e => e.Code)).Contains("PwnedPassword");
26+
}
27+
}
28+
29+
public class PwnedPasswordServiceProvider : IDisposable, IAsyncDisposable
30+
{
31+
public ServiceProvider ServiceProvider { get; } = CreateServiceProvider();
32+
33+
public static ServiceProvider CreateServiceProvider()
34+
{
35+
IServiceCollection services = new ServiceCollection();
36+
services.AddLogging();
37+
services.AddHttpClient("HaveIBeenPwned", c =>
38+
{
39+
c.BaseAddress = new Uri("https://api.pwnedpasswords.com/");
40+
c.DefaultRequestHeaders.UserAgent.ParseAdd("EssentialCSharp.Web/1.0");
41+
c.Timeout = TimeSpan.FromSeconds(10);
42+
});
43+
services.AddTransient<IPasswordValidator<IdentityUser>,
44+
PwnedPasswordValidator<IdentityUser>>();
45+
return services.BuildServiceProvider();
46+
}
47+
48+
public void Dispose()
49+
{
50+
Dispose(disposing: true);
51+
GC.SuppressFinalize(this);
52+
}
53+
54+
protected virtual void Dispose(bool disposing)
55+
{
56+
if (disposing)
57+
{
58+
ServiceProvider.Dispose();
59+
}
60+
}
61+
62+
public async ValueTask DisposeAsync()
63+
{
64+
await ServiceProvider.DisposeAsync().ConfigureAwait(false);
65+
Dispose(disposing: false);
66+
GC.SuppressFinalize(this);
67+
}
68+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using System.Net;
2+
using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators;
3+
using Microsoft.AspNetCore.Identity;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
using Moq;
6+
7+
namespace EssentialCSharp.Web.Tests;
8+
9+
public class PwnedPasswordValidatorTests
10+
{
11+
// SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
12+
// Prefix: 5BAA6, Suffix: 1E4C9B93F3F0682250B6CF8331B7EE68FD8
13+
14+
[Test]
15+
public async Task BreachedPassword_ReturnsFailedResult()
16+
{
17+
// The response contains the real suffix for "password" with a high count.
18+
string responseBody =
19+
"1D2DA4D3D1F8B4BAA725C0A2E3B4D5E6F7A:2\r\n" +
20+
"1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493\r\n" +
21+
"1F2E3D4C5B6A79808172635445362718091:15\r\n";
22+
23+
PwnedPasswordValidator<IdentityUser> validator = CreateValidator(responseBody);
24+
using UserManager<IdentityUser> manager = CreateMockUserManager();
25+
26+
IdentityResult result = await validator.ValidateAsync(manager, new IdentityUser("testuser"), "password");
27+
28+
await Assert.That(result.Succeeded).IsFalse();
29+
await Assert.That(result.Errors.Select(e => e.Code)).Contains("PwnedPassword");
30+
}
31+
32+
[Test]
33+
public async Task SafePassword_ReturnsSuccess()
34+
{
35+
// Response contains suffixes that do NOT match the test password's hash.
36+
string responseBody =
37+
"0000000000000000000000000000000000A:5\r\n" +
38+
"1111111111111111111111111111111111B:12\r\n" +
39+
"2222222222222222222222222222222222C:1\r\n";
40+
41+
PwnedPasswordValidator<IdentityUser> validator = CreateValidator(responseBody);
42+
using UserManager<IdentityUser> manager = CreateMockUserManager();
43+
44+
IdentityResult result = await validator.ValidateAsync(manager, new IdentityUser("testuser"), "s0m3-V3ry-Un1qu3-P@ssw0rd!");
45+
46+
await Assert.That(result.Succeeded).IsTrue();
47+
}
48+
49+
[Test]
50+
public async Task ApiError_FailsOpen_ReturnsSuccess()
51+
{
52+
PwnedPasswordValidator<IdentityUser> validator = CreateValidator(
53+
throwOnSend: new HttpRequestException("Simulated HIBP outage"));
54+
using UserManager<IdentityUser> manager = CreateMockUserManager();
55+
56+
IdentityResult result = await validator.ValidateAsync(manager, new IdentityUser("testuser"), "anything");
57+
58+
await Assert.That(result.Succeeded).IsTrue();
59+
}
60+
61+
[Test]
62+
public async Task PaddedEntry_WithCountZero_IsIgnored()
63+
{
64+
// Simulate a padded response where the matching suffix has count=0.
65+
string responseBody =
66+
"1D2DA4D3D1F8B4BAA725C0A2E3B4D5E6F7A:2\r\n" +
67+
"1E4C9B93F3F0682250B6CF8331B7EE68FD8:0\r\n" +
68+
"1F2E3D4C5B6A79808172635445362718091:15\r\n";
69+
70+
PwnedPasswordValidator<IdentityUser> validator = CreateValidator(responseBody);
71+
using UserManager<IdentityUser> manager = CreateMockUserManager();
72+
73+
IdentityResult result = await validator.ValidateAsync(manager, new IdentityUser("testuser"), "password");
74+
75+
await Assert.That(result.Succeeded).IsTrue();
76+
}
77+
78+
[Test]
79+
public async Task ServiceUnavailableResponse_FailsOpen_ReturnsSuccess()
80+
{
81+
PwnedPasswordValidator<IdentityUser> validator =
82+
CreateValidator("", HttpStatusCode.ServiceUnavailable);
83+
using UserManager<IdentityUser> manager = CreateMockUserManager();
84+
85+
IdentityResult result = await validator.ValidateAsync(manager, new IdentityUser("testuser"), "anything");
86+
87+
await Assert.That(result.Succeeded).IsTrue();
88+
}
89+
90+
[Test]
91+
public async Task ValidateAsync_SendsCorrectKAnonymityRequest()
92+
{
93+
// SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
94+
// Validator must send only the 5-char prefix "5BAA6", never the full hash.
95+
HttpRequestMessage? capturedRequest = null;
96+
MockHttpMessageHandler handler = new((request, _) =>
97+
{
98+
capturedRequest = request;
99+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
100+
{
101+
Content = new StringContent(string.Empty)
102+
});
103+
});
104+
PwnedPasswordValidator<IdentityUser> validator = BuildValidator(handler);
105+
using UserManager<IdentityUser> manager = CreateMockUserManager();
106+
107+
await validator.ValidateAsync(manager, new IdentityUser("testuser"), "password");
108+
109+
await Assert.That(capturedRequest).IsNotNull();
110+
await Assert.That(capturedRequest!.RequestUri!.PathAndQuery).IsEqualTo("/range/5BAA6");
111+
await Assert.That(capturedRequest.Headers.Contains("Add-Padding")).IsTrue();
112+
await Assert.That(capturedRequest.Headers.GetValues("Add-Padding").Single()).IsEqualTo("true");
113+
}
114+
115+
116+
[Test]
117+
public async Task NullPassword_ThrowsArgumentNullException()
118+
{
119+
PwnedPasswordValidator<IdentityUser> validator = CreateValidator("UNUSED:0\r\n");
120+
using UserManager<IdentityUser> manager = CreateMockUserManager();
121+
122+
await Assert.That(async () => await validator.ValidateAsync(manager, new IdentityUser("testuser"), null!))
123+
.ThrowsExactly<ArgumentNullException>()
124+
.And
125+
.HasMessageContaining("password");
126+
}
127+
128+
[Test]
129+
public async Task NullManager_ThrowsArgumentNullException()
130+
{
131+
PwnedPasswordValidator<IdentityUser> validator = CreateValidator("UNUSED:0\r\n");
132+
133+
await Assert.That(async () => await validator.ValidateAsync(null!, new IdentityUser("testuser"), "test"))
134+
.ThrowsExactly<ArgumentNullException>()
135+
.And
136+
.HasMessageContaining("manager");
137+
}
138+
139+
private static PwnedPasswordValidator<IdentityUser> CreateValidator(
140+
string responseBody, HttpStatusCode statusCode = HttpStatusCode.OK)
141+
{
142+
MockHttpMessageHandler handler = new((_, _) =>
143+
Task.FromResult(new HttpResponseMessage(statusCode)
144+
{
145+
Content = new StringContent(responseBody)
146+
}));
147+
148+
return BuildValidator(handler);
149+
}
150+
151+
private static PwnedPasswordValidator<IdentityUser> CreateValidator(
152+
HttpRequestException throwOnSend)
153+
{
154+
MockHttpMessageHandler handler = new((_, _) =>
155+
throw throwOnSend);
156+
157+
return BuildValidator(handler);
158+
}
159+
160+
private static PwnedPasswordValidator<IdentityUser> BuildValidator(MockHttpMessageHandler handler)
161+
{
162+
HttpClient httpClient = new(handler)
163+
{
164+
BaseAddress = new Uri("https://api.pwnedpasswords.com/")
165+
};
166+
167+
Mock<IHttpClientFactory> factory = new();
168+
factory.Setup(f => f.CreateClient("HaveIBeenPwned")).Returns(httpClient);
169+
170+
return new PwnedPasswordValidator<IdentityUser>(
171+
factory.Object,
172+
NullLogger<PwnedPasswordValidator<IdentityUser>>.Instance);
173+
}
174+
175+
private static UserManager<IdentityUser> CreateMockUserManager()
176+
{
177+
Mock<IUserStore<IdentityUser>> store = new();
178+
return new UserManager<IdentityUser>(
179+
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
180+
}
181+
182+
private sealed class MockHttpMessageHandler(
183+
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
184+
: HttpMessageHandler
185+
{
186+
protected override Task<HttpResponseMessage> SendAsync(
187+
HttpRequestMessage request, CancellationToken cancellationToken)
188+
=> handler(request, cancellationToken);
189+
}
190+
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ ViewData["ActivePage"] = ManageNavPages.ChangePassword;
2121
<label asp-for="Input.NewPassword" class="form-label"></label>
2222
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
2323
</div>
24+
<input type="hidden" id="change-password-user-email" value="@Model.UserEmail" />
25+
<input type="hidden" id="change-password-user-name" value="@Model.UserName" />
26+
<partial name="~/Areas/Identity/Pages/_PasswordStrengthMeter.cshtml"
27+
model='new EssentialCSharp.Web.Areas.Identity.Pages.PasswordStrengthMeterModel("Input_NewPassword", "#change-password-user-email,#change-password-user-name")' />
2428
<div class="form-floating mb-3">
2529
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
2630
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
@@ -33,4 +37,5 @@ ViewData["ActivePage"] = ManageNavPages.ChangePassword;
3337

3438
@section Scripts {
3539
<partial name="_ValidationScriptsPartial" />
40+
<script type="module" src="~/js/password-strength.js" asp-append-version="true"></script>
3641
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public InputModel Input
2222
[TempData]
2323
public string? StatusMessage { get; set; }
2424

25+
public string? UserEmail { get; private set; }
26+
public string? UserName { get; private set; }
27+
2528
public class InputModel
2629
{
2730
[Required]
@@ -49,6 +52,9 @@ public async Task<IActionResult> OnGetAsync()
4952
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
5053
}
5154

55+
UserEmail = user.Email;
56+
UserName = user.UserName;
57+
5258
bool hasPassword = await userManager.HasPasswordAsync(user);
5359
if (!hasPassword)
5460
{
@@ -62,6 +68,9 @@ public async Task<IActionResult> OnPostAsync()
6268
{
6369
if (!ModelState.IsValid)
6470
{
71+
EssentialCSharpWebUser? currentUser = await userManager.GetUserAsync(User);
72+
UserEmail = currentUser?.Email;
73+
UserName = currentUser?.UserName;
6574
return Page();
6675
}
6776

@@ -71,6 +80,9 @@ public async Task<IActionResult> OnPostAsync()
7180
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
7281
}
7382

83+
UserEmail = user.Email;
84+
UserName = user.UserName;
85+
7486
if (Input.NewPassword is null)
7587
{
7688
ModelState.AddModelError(string.Empty, "New password is required.");

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ ViewData["ActivePage"] = ManageNavPages.ChangePassword;
2020
<label asp-for="Input.NewPassword" class="form-label"></label>
2121
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
2222
</div>
23+
<input type="hidden" id="set-password-user-email" value="@Model.UserEmail" />
24+
<input type="hidden" id="set-password-user-name" value="@Model.UserName" />
25+
<partial name="~/Areas/Identity/Pages/_PasswordStrengthMeter.cshtml"
26+
model='new EssentialCSharp.Web.Areas.Identity.Pages.PasswordStrengthMeterModel("Input_NewPassword", "#set-password-user-email,#set-password-user-name")' />
2327
<div class="form-floating mb-3">
2428
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password."/>
2529
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
@@ -32,4 +36,5 @@ ViewData["ActivePage"] = ManageNavPages.ChangePassword;
3236

3337
@section Scripts {
3438
<partial name="_ValidationScriptsPartial" />
39+
<script type="module" src="~/js/password-strength.js" asp-append-version="true"></script>
3540
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public InputModel Input
2121
[TempData]
2222
public string? StatusMessage { get; set; }
2323

24+
public string? UserEmail { get; private set; }
25+
public string? UserName { get; private set; }
26+
2427
public class InputModel
2528
{
2629
[Required]
@@ -43,6 +46,9 @@ public async Task<IActionResult> OnGetAsync()
4346
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
4447
}
4548

49+
UserEmail = user.Email;
50+
UserName = user.UserName;
51+
4652
bool hasPassword = await userManager.HasPasswordAsync(user);
4753

4854
if (hasPassword)
@@ -57,6 +63,9 @@ public async Task<IActionResult> OnPostAsync()
5763
{
5864
if (!ModelState.IsValid)
5965
{
66+
EssentialCSharpWebUser? currentUser = await userManager.GetUserAsync(User);
67+
UserEmail = currentUser?.Email;
68+
UserName = currentUser?.UserName;
6069
return Page();
6170
}
6271

@@ -66,6 +75,9 @@ public async Task<IActionResult> OnPostAsync()
6675
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
6776
}
6877

78+
UserEmail = user.Email;
79+
UserName = user.UserName;
80+
6981
if (Input.NewPassword is null)
7082
{
7183
StatusMessage = "Please enter a new password.";

EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
<label asp-for="Input.Password">Password</label>
4040
<span asp-validation-for="Input.Password" class="text-danger"></span>
4141
</div>
42+
<partial name="~/Areas/Identity/Pages/_PasswordStrengthMeter.cshtml"
43+
model='new EssentialCSharp.Web.Areas.Identity.Pages.PasswordStrengthMeterModel("Input_Password", "#Input_Email,#Input_UserName,#Input_FirstName,#Input_LastName")' />
4244
<div class="form-floating mb-3">
4345
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
4446
<label asp-for="Input.ConfirmPassword">Confirm Password</label>
@@ -90,4 +92,5 @@
9092
@section Scripts {
9193
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
9294
<partial name="_ValidationScriptsPartial" />
95+
<script type="module" src="~/js/password-strength.js" asp-append-version="true"></script>
9396
}

0 commit comments

Comments
 (0)