Skip to content

Commit 68b9f55

Browse files
Integrate HIBP breach result into strength score bar
When a password is found in a breach, force the meter to score 0 (red bar, 'Very weak') with 'This password appeared in a data breach.' shown in the unified warning slot. Crack time is hidden (irrelevant for a known-breach password). Previously a separate warning element floated in the label row, leaving the score bar potentially showing 'Strong' alongside it. Now there is one unified signal. Remove the .password-hibp-warning element entirely; clearMeter and onInput no longer reference it. HIBP check stays blur-only (fewer API calls than running on every keystroke). Change warning text color from yellow to red (text-danger) Better contrast and more appropriate severity a breach warning or zxcvbn pattern diagnosis warrants red, not yellow. check HIBP while typing - Run HIBP check inside debounced onInput (fires 300ms after typing stops), not only on blur user sees breach warning without leaving the field - Skip HIBP when password is below minimum length (fails server validation first) - Fix stale zxcvbn result: staleness check after await ensureZxcvbn() - Fix ensureZxcvbn error handling: reset promise on failure to allow retry - Immediate clearMeter when field emptied (no 300ms debounce lag) - Add change event listener for autofill / password-manager fills - Remove tabindex=-1 from show-password toggle (WCAG 2.1 SC 2.1.1 compliance) - Simplify: blur-based HIBP handler removed; inputGeneration replaces blurGeneration
1 parent 0179313 commit 68b9f55

2 files changed

Lines changed: 52 additions & 41 deletions

File tree

EssentialCSharp.Web/Areas/Identity/Pages/_PasswordStrengthMeter.cshtml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,15 @@
2525
<button type="button"
2626
class="btn btn-sm btn-outline-secondary password-show-toggle py-0 px-1 border-0"
2727
aria-label="Show password"
28-
aria-pressed="false"
29-
tabindex="-1">
28+
aria-pressed="false">
3029
<i class="bi bi-eye" aria-hidden="true"></i>
3130
</button>
3231
</div>
33-
<div class="d-flex justify-content-between align-items-center mt-1">
32+
<div class="d-flex gap-2 align-items-center mt-1">
3433
<small class="password-strength-label text-muted" aria-live="polite"></small>
35-
<small class="password-strength-cracktime text-muted d-none" aria-live="polite"></small>
36-
<small class="password-hibp-warning text-danger d-none" role="alert">
37-
⚠️ This password has appeared in a known data breach.
38-
</small>
34+
<small class="password-strength-cracktime text-muted ms-auto d-none" aria-live="polite"></small>
3935
</div>
40-
<small class="password-strength-warning text-warning d-block mt-1 d-none" aria-live="polite"></small>
36+
<small class="password-strength-warning text-danger d-block mt-1 d-none" aria-live="polite"></small>
4137
<small class="password-strength-suggestions text-muted d-block mt-1 d-none" aria-live="polite"></small>
4238
<ul class="password-requirements list-unstyled mt-1 mb-0 d-none" aria-live="polite">
4339
<li class="req-item small" data-rule="minlength">

EssentialCSharp.Web/wwwroot/js/password-strength.js

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,19 @@ async function ensureZxcvbn() {
3030
import('@zxcvbn-ts/language-en'),
3131
]);
3232
}
33-
const [zxcvbnCommon, zxcvbnEn] = await zxcvbnLoadPromise;
34-
zxcvbnOptions.setOptions({
35-
translations: zxcvbnEn.translations,
36-
graphs: zxcvbnCommon.adjacencyGraphs,
37-
dictionary: { ...zxcvbnCommon.dictionary, ...zxcvbnEn.dictionary },
38-
useLevenshteinDistance: true,
39-
});
40-
zxcvbnReady = true;
33+
try {
34+
const [zxcvbnCommon, zxcvbnEn] = await zxcvbnLoadPromise;
35+
zxcvbnOptions.setOptions({
36+
translations: zxcvbnEn.translations,
37+
graphs: zxcvbnCommon.adjacencyGraphs,
38+
dictionary: { ...zxcvbnCommon.dictionary, ...zxcvbnEn.dictionary },
39+
useLevenshteinDistance: true,
40+
});
41+
zxcvbnReady = true;
42+
} catch (err) {
43+
zxcvbnLoadPromise = null; // Allow retry on next input
44+
throw err;
45+
}
4146
}
4247

4348
function debounce(fn, ms) {
@@ -131,7 +136,6 @@ function clearMeter(container) {
131136
if (suggestionsEl) { suggestionsEl.textContent = ''; suggestionsEl.classList.add('d-none'); }
132137
const crackTimeEl = container.querySelector('.password-strength-cracktime');
133138
if (crackTimeEl) { crackTimeEl.textContent = ''; crackTimeEl.classList.add('d-none'); }
134-
container.querySelector('.password-hibp-warning').classList.add('d-none');
135139
}
136140

137141
// --- Requirements checklist ---
@@ -203,7 +207,7 @@ function initRequirements(container, passwordInput) {
203207

204208
function initShowToggle(container, passwordInput) {
205209
const btn = container.querySelector('.password-show-toggle');
206-
if (!btn) return;
210+
if (!btn) return btn;
207211

208212
btn.addEventListener('click', () => {
209213
const isShowing = passwordInput.type === 'text';
@@ -216,6 +220,8 @@ function initShowToggle(container, passwordInput) {
216220
btn.setAttribute('aria-label', isShowing ? 'Show password' : 'Hide password');
217221
btn.setAttribute('aria-pressed', String(!isShowing));
218222
});
223+
224+
return btn;
219225
}
220226

221227
async function checkHibp(password) {
@@ -251,6 +257,7 @@ async function checkHibp(password) {
251257
function initMeter(container) {
252258
const passwordSelector = container.dataset.passwordField;
253259
const userInputFieldIds = container.dataset.userInputFields || '';
260+
const minLength = parseInt(container.dataset.minLength, 10) || 15;
254261

255262
const passwordInput = document.querySelector(passwordSelector);
256263
if (!passwordInput) return;
@@ -259,7 +266,7 @@ function initMeter(container) {
259266
initShowToggle(container, passwordInput);
260267

261268
let hibpWarningActive = false;
262-
let blurGeneration = 0;
269+
let inputGeneration = 0;
263270

264271
const onInput = debounce(async () => {
265272
const password = passwordInput.value;
@@ -271,39 +278,47 @@ function initMeter(container) {
271278
}
272279

273280
await ensureZxcvbn();
281+
// Guard against stale result if the user kept typing during the initial load
282+
if (password !== passwordInput.value) return;
274283
const userInputs = getUserInputValues(userInputFieldIds);
275284
const result = zxcvbn(password, userInputs);
276-
updateMeter(container, result.score, result.feedback, result.crackTimesDisplay);
277285

278-
// Re-show HIBP warning if previously triggered and password unchanged
279-
const hibpEl = container.querySelector('.password-hibp-warning');
286+
// Render zxcvbn result immediately; HIBP check runs asynchronously below.
280287
if (hibpWarningActive) {
281-
hibpEl.classList.remove('d-none');
288+
updateMeter(container, 0, { warning: 'This password appeared in a data breach.' }, null);
289+
} else {
290+
updateMeter(container, result.score, result.feedback, result.crackTimesDisplay);
282291
}
283-
}, 300);
284292

285-
const onBlur = async () => {
286-
const password = passwordInput.value;
287-
if (!password) return;
288-
289-
const generation = ++blurGeneration;
290-
const hibpEl = container.querySelector('.password-hibp-warning');
291-
const breached = await checkHibp(password);
292-
// Discard if the password changed or a newer blur already started.
293-
if (passwordInput.value !== password || generation !== blurGeneration) return;
294-
hibpWarningActive = breached;
295-
hibpEl.classList.toggle('d-none', !breached);
296-
};
293+
// Check HIBP while the user is still typing — no need to wait for blur.
294+
// Only fires once the password meets minimum length; short passwords fail
295+
// server-side validation before HIBP is relevant.
296+
if (password.length >= minLength) {
297+
const gen = inputGeneration;
298+
const breached = await checkHibp(password);
299+
// Discard if the user typed something new while the request was in flight.
300+
if (passwordInput.value !== password || inputGeneration !== gen) return;
301+
hibpWarningActive = breached;
302+
if (breached) {
303+
updateMeter(container, 0, { warning: 'This password appeared in a data breach.' }, null);
304+
}
305+
}
306+
}, 300);
297307

298-
// Clear HIBP warning when user starts changing password again
308+
// Clear HIBP override immediately when user starts changing password again.
309+
// Also clear the meter instantly when the field becomes empty (no 300 ms debounce delay).
299310
passwordInput.addEventListener('input', () => {
300311
hibpWarningActive = false;
301-
blurGeneration++;
302-
container.querySelector('.password-hibp-warning').classList.add('d-none');
303-
onInput();
312+
inputGeneration++;
313+
if (!passwordInput.value) {
314+
clearMeter(container);
315+
} else {
316+
onInput();
317+
}
304318
});
305319

306-
passwordInput.addEventListener('blur', onBlur);
320+
// Catch autofill / password-manager injections which dispatch `change` but not `input`
321+
passwordInput.addEventListener('change', onInput);
307322

308323
// Recompute strength score when related fields (email, username) change,
309324
// since they are used as zxcvbn user inputs to penalise guessable passwords.

0 commit comments

Comments
 (0)