Skip to content

Commit 2097823

Browse files
improve: password meter lazy loading, crack time, split userInputs, Levenshtein
- Convert language pack imports to dynamic import() (true lazy loading) - Add crack time display: crackTimesDisplay.onlineThrottling100PerHour - Separate warning/suggestions into distinct DOM elements - Enable useLevenshteinDistance: true for better near-miss detection - Fix getUserInputValues to split emails/names into parts so substrings like 'johndoe' are caught when full value is 'johndoe@gmail.com' - Fix @zxcvbn-ts/language-en version: 3.0.4 -> 3.0.2 (3.0.4 does not exist)
1 parent 6ebf002 commit 2097823

3 files changed

Lines changed: 63 additions & 15 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
</div>
2323
<div class="d-flex justify-content-between align-items-center mt-1">
2424
<small class="password-strength-label text-muted" aria-live="polite"></small>
25+
<small class="password-strength-cracktime text-muted d-none" aria-live="polite"></small>
2526
<small class="password-hibp-warning text-danger d-none" role="alert">
2627
⚠️ This password has appeared in a known data breach.
2728
</small>
2829
</div>
29-
<small class="password-strength-feedback text-muted d-block mt-1" aria-live="polite"></small>
30+
<small class="password-strength-warning text-warning d-block mt-1 d-none" aria-live="polite"></small>
31+
<small class="password-strength-suggestions text-muted d-block mt-1 d-none" aria-live="polite"></small>
3032
</div>

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
{ "vuetify", "https://cdn.jsdelivr.net/npm/vuetify@3.9.2/dist/vuetify.esm.js" },
2222
{ "@zxcvbn-ts/core", "https://esm.sh/@zxcvbn-ts/core@3.0.4" },
2323
{ "@zxcvbn-ts/language-common", "https://esm.sh/@zxcvbn-ts/language-common@3.0.4" },
24-
{ "@zxcvbn-ts/language-en", "https://esm.sh/@zxcvbn-ts/language-en@3.0.4" },
24+
{ "@zxcvbn-ts/language-en", "https://esm.sh/@zxcvbn-ts/language-en@3.0.2" },
2525
}, null, null);
2626
var devMap = new ImportMapDefinition(
2727
new Dictionary<string, string>
@@ -31,7 +31,7 @@
3131
{ "vuetify", "https://cdn.jsdelivr.net/npm/vuetify@3.9.2/dist/vuetify.esm.js" },
3232
{ "@zxcvbn-ts/core", "https://esm.sh/@zxcvbn-ts/core@3.0.4" },
3333
{ "@zxcvbn-ts/language-common", "https://esm.sh/@zxcvbn-ts/language-common@3.0.4" },
34-
{ "@zxcvbn-ts/language-en", "https://esm.sh/@zxcvbn-ts/language-en@3.0.4" },
34+
{ "@zxcvbn-ts/language-en", "https://esm.sh/@zxcvbn-ts/language-en@3.0.2" },
3535
}, null, null);
3636
}
3737
<!DOCTYPE html>
@@ -127,7 +127,7 @@
127127
}
128128
</style>
129129
<!-- hCaptcha Script -->
130-
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
130+
<script src="https://js.hcaptcha.com/1/api.js" defer></script>
131131
@await RenderSectionAsync("HeadAppend", required: false)
132132
</head>
133133
<body>

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

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
*/
1010

1111
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
12-
import * as zxcvbnCommon from '@zxcvbn-ts/language-common';
13-
import * as zxcvbnEn from '@zxcvbn-ts/language-en';
1412

1513
const SCORE_CONFIG = [
1614
{ label: 'Very weak', barClass: 'bg-danger', width: 20 },
@@ -21,13 +19,22 @@ const SCORE_CONFIG = [
2119
];
2220

2321
let zxcvbnReady = false;
22+
let zxcvbnLoadPromise = null;
2423

2524
async function ensureZxcvbn() {
2625
if (zxcvbnReady) return;
26+
if (!zxcvbnLoadPromise) {
27+
zxcvbnLoadPromise = Promise.all([
28+
import('@zxcvbn-ts/language-common'),
29+
import('@zxcvbn-ts/language-en'),
30+
]);
31+
}
32+
const [zxcvbnCommon, zxcvbnEn] = await zxcvbnLoadPromise;
2733
zxcvbnOptions.setOptions({
2834
translations: zxcvbnEn.translations,
2935
graphs: zxcvbnCommon.adjacencyGraphs,
3036
dictionary: { ...zxcvbnCommon.dictionary, ...zxcvbnEn.dictionary },
37+
useLevenshteinDistance: true,
3138
});
3239
zxcvbnReady = true;
3340
}
@@ -42,17 +49,36 @@ function debounce(fn, ms) {
4249

4350
function getUserInputValues(userInputFieldIds) {
4451
if (!userInputFieldIds) return [];
45-
return userInputFieldIds
52+
const raw = userInputFieldIds
4653
.split(',')
4754
.map(sel => document.querySelector(sel.trim())?.value)
4855
.filter(Boolean);
56+
57+
const parts = [];
58+
for (const val of raw) {
59+
parts.push(val);
60+
// Split email addresses: "john.doe@gmail.com" → "john.doe", "john", "doe", "gmail"
61+
if (val.includes('@')) {
62+
const [local, domain] = val.split('@');
63+
parts.push(local);
64+
parts.push(...local.split(/[._+\-]/));
65+
const domainBase = domain?.split('.')[0];
66+
if (domainBase) parts.push(domainBase);
67+
}
68+
// Split on common word-separators: spaces, dots, underscores, hyphens
69+
parts.push(...val.split(/[\s._@+\-]+/));
70+
}
71+
// Deduplicate and discard single-character fragments (too noisy)
72+
return [...new Set(parts.filter(s => s.length >= 2))];
4973
}
5074

51-
function updateMeter(container, score, feedback) {
75+
function updateMeter(container, score, feedback, crackTimesDisplay) {
5276
const config = SCORE_CONFIG[score];
5377
const bar = container.querySelector('.password-strength-bar');
5478
const label = container.querySelector('.password-strength-label');
55-
const feedbackEl = container.querySelector('.password-strength-feedback');
79+
const warningEl = container.querySelector('.password-strength-warning');
80+
const suggestionsEl = container.querySelector('.password-strength-suggestions');
81+
const crackTimeEl = container.querySelector('.password-strength-cracktime');
5682

5783
// Reset bar classes
5884
bar.className = 'progress-bar password-strength-bar ' + config.barClass;
@@ -64,10 +90,25 @@ function updateMeter(container, score, feedback) {
6490

6591
label.textContent = config.label;
6692

67-
const suggestions = [feedback.warning, ...(feedback.suggestions ?? [])]
68-
.filter(Boolean)
69-
.join(' ');
70-
feedbackEl.textContent = suggestions;
93+
// Warning: what's wrong (null for score >= 3)
94+
if (warningEl) {
95+
warningEl.textContent = feedback.warning ?? '';
96+
warningEl.classList.toggle('d-none', !feedback.warning);
97+
}
98+
99+
// Suggestions: how to improve
100+
if (suggestionsEl) {
101+
const tips = (feedback.suggestions ?? []).filter(Boolean);
102+
suggestionsEl.textContent = tips.join(' ');
103+
suggestionsEl.classList.toggle('d-none', tips.length === 0);
104+
}
105+
106+
// Crack time estimate (online throttled — most relevant for web app context)
107+
if (crackTimeEl) {
108+
const display = crackTimesDisplay?.onlineThrottling100PerHour;
109+
crackTimeEl.textContent = display ? `Time to crack: ${display}` : '';
110+
crackTimeEl.classList.toggle('d-none', !display);
111+
}
71112
}
72113

73114
function clearMeter(container) {
@@ -76,7 +117,12 @@ function clearMeter(container) {
76117
bar.style.width = '0';
77118
container.querySelector('.progress').setAttribute('aria-valuenow', '0');
78119
container.querySelector('.password-strength-label').textContent = '';
79-
container.querySelector('.password-strength-feedback').textContent = '';
120+
const warningEl = container.querySelector('.password-strength-warning');
121+
if (warningEl) { warningEl.textContent = ''; warningEl.classList.add('d-none'); }
122+
const suggestionsEl = container.querySelector('.password-strength-suggestions');
123+
if (suggestionsEl) { suggestionsEl.textContent = ''; suggestionsEl.classList.add('d-none'); }
124+
const crackTimeEl = container.querySelector('.password-strength-cracktime');
125+
if (crackTimeEl) { crackTimeEl.textContent = ''; crackTimeEl.classList.add('d-none'); }
80126
container.querySelector('.password-hibp-warning').classList.add('d-none');
81127
}
82128

@@ -132,7 +178,7 @@ function initMeter(container) {
132178
await ensureZxcvbn();
133179
const userInputs = getUserInputValues(userInputFieldIds);
134180
const result = zxcvbn(password, userInputs);
135-
updateMeter(container, result.score, result.feedback);
181+
updateMeter(container, result.score, result.feedback, result.crackTimesDisplay);
136182

137183
// Re-show HIBP warning if previously triggered and password unchanged
138184
const hibpEl = container.querySelector('.password-hibp-warning');

0 commit comments

Comments
 (0)