@fr0st/ui-passwordstrength 2.0.7 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,52 +1,255 @@
1
1
  import $ from '@fr0st/query';
2
- import { BaseComponent } from '@fr0st/ui';
2
+ import { BaseComponent, generateId, getDataset } from '@fr0st/ui';
3
+ import { getStrength } from './strength.js';
3
4
 
4
5
  /**
5
- * PasswordStrength Class
6
- * @class
6
+ * @typedef {object} PasswordStrengthLevel
7
+ * @property {string} class The CSS class applied to the progress bar.
8
+ * @property {number} score The minimum score for the level.
9
+ * @property {string} [text] The label displayed in the progress bar.
10
+ */
11
+
12
+ /**
13
+ * @callback PasswordStrengthScorer
14
+ * @param {string} password The password.
15
+ * @param {string[]} commonPasswords The common passwords.
16
+ * @returns {number} The password strength, from 0 to 100.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} PasswordStrengthOptions
21
+ * @property {string[]} [commonPasswords] Passwords to score as very weak.
22
+ * @property {string|null} [container=null] The selector for the progress container.
23
+ * @property {PasswordStrengthLevel[]} [levels] The ordered password-strength levels.
24
+ * @property {PasswordStrengthScorer} [scorer] Calculates the password strength.
25
+ * @property {boolean} [striped=false] Whether to display a striped progress bar.
26
+ */
27
+
28
+ /**
29
+ * Displays the strength of a password input as a progress bar.
30
+ * @augments {BaseComponent<PasswordStrengthOptions>}
7
31
  */
8
32
  export default class PasswordStrength extends BaseComponent {
33
+ static classes = {
34
+ progress: 'progress mt-2',
35
+ progressBar: 'progress-bar',
36
+ progressBarStriped: 'progress-bar-striped',
37
+ };
38
+ /** @type {PasswordStrengthOptions} */
39
+ static defaults = {
40
+ commonPasswords: [
41
+ '123456',
42
+ '12345678',
43
+ '123456789',
44
+ '1234567890',
45
+ '000000',
46
+ '111111',
47
+ 'abc123',
48
+ 'admin',
49
+ 'dragon',
50
+ 'iloveyou',
51
+ 'letmein',
52
+ 'monkey',
53
+ 'password',
54
+ 'password1',
55
+ 'password123',
56
+ 'qwerty',
57
+ 'qwerty123',
58
+ 'welcome',
59
+ ],
60
+ levels: [
61
+ {
62
+ score: 0,
63
+ class: 'text-bg-danger',
64
+ text: 'Very Weak',
65
+ },
66
+ {
67
+ score: 20,
68
+ class: 'text-bg-danger',
69
+ text: 'Weak',
70
+ },
71
+ {
72
+ score: 40,
73
+ class: 'text-bg-warning',
74
+ text: 'Normal',
75
+ },
76
+ {
77
+ score: 60,
78
+ class: 'text-bg-success',
79
+ text: 'Strong',
80
+ },
81
+ {
82
+ score: 80,
83
+ class: 'text-bg-success',
84
+ text: 'Very Strong',
85
+ },
86
+ ],
87
+ container: null,
88
+ scorer: getStrength,
89
+ striped: false,
90
+ };
91
+
92
+ #container;
93
+ #describedBy;
94
+ #form;
95
+ #progress;
96
+ #progressBar;
97
+ #resetHandler;
98
+
99
+ /**
100
+ * Calculates the strength of a password.
101
+ * @param {string} password The password.
102
+ * @param {string[]} [commonPasswords=this.defaults.commonPasswords] Passwords to score as very weak.
103
+ * @returns {number} The password strength, from 0 to 100.
104
+ */
105
+ static getStrength(password, commonPasswords = this.defaults.commonPasswords) {
106
+ return getStrength(password, commonPasswords);
107
+ }
108
+
9
109
  /**
10
- * New PasswordStrength constructor.
110
+ * Creates a PasswordStrength.
11
111
  * @param {HTMLElement} node The input node.
12
- * @param {object} [options] The options to create the PasswordStrength with.
112
+ * @param {PasswordStrengthOptions} [options] The PasswordStrength options.
13
113
  */
14
114
  constructor(node, options) {
15
115
  super(node, options);
16
116
 
17
- if (this._options.container) {
18
- this._container = $.findOne(this._options.container);
117
+ const overrides = { ...getDataset(node), ...options };
118
+
119
+ // Replace supplied arrays instead of merging them with default entries.
120
+ for (const key of ['levels', 'commonPasswords']) {
121
+ if (Array.isArray(overrides[key])) {
122
+ this.options[key].length = 0;
123
+ $._extend(this.options[key], overrides[key]);
124
+ }
125
+ }
126
+
127
+ this.#form = this.node.form;
128
+
129
+ if (this.options.container) {
130
+ this.#container = $.findOne(this.options.container);
19
131
  } else {
20
- this._container = $.closest(this._node, ':not(.form-input):not(.input-group)');
132
+ this.#container = $.closest(this.node, ':not(.form-input):not(.input-group)').shift();
21
133
  }
22
134
 
23
- this._render();
24
- this._refresh();
25
- this._events();
135
+ this.#render();
136
+ this.#refresh();
137
+ this.#events();
26
138
  }
27
139
 
28
- /**
29
- * Dispose the PasswordStrength.
30
- */
140
+ /** @inheritdoc */
31
141
  dispose() {
32
- $.remove(this._progress);
33
- $.removeEvent(this._node, 'input.ui.passwordstrength');
34
- $.removeAttribute(this._node, 'aria-describedby');
142
+ $.remove(this.#progress);
143
+ $.removeEvent(this.node, 'input.ui.passwordstrength');
144
+
145
+ if (this.#form) {
146
+ $.removeEvent(this.#form, 'reset.ui.passwordstrength', this.#resetHandler);
147
+ }
148
+
149
+ if (this.#describedBy === null) {
150
+ $.removeAttribute(this.node, 'aria-describedby');
151
+ } else {
152
+ $.setAttribute(this.node, { 'aria-describedby': this.#describedBy });
153
+ }
35
154
 
36
- this._container = null;
37
- this._progress = null;
38
- this._progressBar = null;
155
+ this.#container = null;
156
+ this.#form = null;
157
+ this.#progress = null;
158
+ this.#progressBar = null;
159
+ this.#resetHandler = null;
39
160
 
40
161
  super.dispose();
41
162
  }
42
163
 
43
164
  /**
44
- * Get the password strength.
45
- * @return {number} The password strength. (0, 100)
165
+ * Gets the password strength.
166
+ * @returns {number} The password strength, from 0 to 100.
46
167
  */
47
168
  getStrength() {
48
- const value = $.getValue(this._node);
169
+ const value = $.getValue(this.node);
170
+ const strength = Number(this.options.scorer(
171
+ value,
172
+ this.options.commonPasswords,
173
+ ));
174
+
175
+ return Number.isFinite(strength) ?
176
+ $._clamp(strength, 0, 100) :
177
+ 0;
178
+ }
179
+
180
+ /**
181
+ * Attaches events for the PasswordStrength.
182
+ */
183
+ #events() {
184
+ if (this.#form) {
185
+ this.#resetHandler = (event) => {
186
+ setTimeout(() => {
187
+ if (this.node && !event.defaultPrevented) {
188
+ this.#refresh();
189
+ }
190
+ }, 0);
191
+ };
192
+
193
+ $.addEvent(this.#form, 'reset.ui.passwordstrength', this.#resetHandler);
194
+ }
195
+
196
+ $.addEvent(this.node, 'input.ui.passwordstrength', (_) => {
197
+ this.#refresh();
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Refreshes the password strength.
203
+ */
204
+ #refresh() {
205
+ const strength = this.getStrength();
206
+
207
+ const nextLevel = this.options.levels.findLast(
208
+ (level) => strength >= level.score,
209
+ );
210
+
211
+ $.setStyle(this.#progressBar, { width: `${strength}%` });
212
+ $.setAttribute(this.#progressBar, {
213
+ 'class': this.constructor.classes.progressBar,
214
+ 'aria-valuenow': strength,
215
+ });
216
+
217
+ $.addClass(this.#progressBar, nextLevel.class);
218
+
219
+ if (this.options.striped) {
220
+ $.addClass(this.#progressBar, this.constructor.classes.progressBarStriped);
221
+ }
222
+
223
+ $.setText(this.#progressBar, nextLevel.text ?? '');
224
+ }
225
+
226
+ /**
227
+ * Renders the password strength element.
228
+ */
229
+ #render() {
230
+ this.#describedBy = $.getAttribute(this.node, 'aria-describedby');
231
+ this.#progress = $.create('div', {
232
+ class: this.constructor.classes.progress,
233
+ });
234
+
235
+ const id = generateId('password-strength');
236
+
237
+ this.#progressBar = $.create('div', {
238
+ attributes: {
239
+ 'id': id,
240
+ 'role': 'progressbar',
241
+ 'aria-valuemin': 0,
242
+ 'aria-valuemax': 100,
243
+ },
244
+ });
245
+
246
+ $.append(this.#progress, this.#progressBar);
247
+ $.append(this.#container, this.#progress);
248
+
249
+ const describedBy = [this.#describedBy, id]
250
+ .filter(Boolean)
251
+ .join(' ');
49
252
 
50
- return this.constructor.getStrength(value);
253
+ $.setAttribute(this.node, { 'aria-describedby': describedBy });
51
254
  }
52
255
  }
@@ -0,0 +1,273 @@
1
+ import $ from '@fr0st/query';
2
+
3
+ const characterTypes = [
4
+ { pattern: /\p{Ll}/u, type: 'lower' },
5
+ { pattern: /\p{Lu}/u, type: 'upper' },
6
+ { pattern: /\p{N}/u, type: 'number' },
7
+ ];
8
+
9
+ const keyboardSequences = [
10
+ '1234567890',
11
+ 'qwertyuiop',
12
+ 'asdfghjkl',
13
+ 'zxcvbnm',
14
+ ].flatMap(
15
+ (row) => [
16
+ row,
17
+ Array.from(row).reverse().join(''),
18
+ ],
19
+ );
20
+
21
+ const leetCharacters = {
22
+ '$': 's',
23
+ '0': 'o',
24
+ '3': 'e',
25
+ '4': 'a',
26
+ '5': 's',
27
+ '7': 't',
28
+ '@': 'a',
29
+ };
30
+
31
+ const lengthBands = [
32
+ { end: 5, max: 19, min: 1, start: 1 },
33
+ { end: 8, max: 39, min: 20, start: 6 },
34
+ { end: 11, max: 59, min: 40, start: 9 },
35
+ { end: 15, max: 79, min: 60, start: 12 },
36
+ { end: 24, max: 100, min: 80, start: 16 },
37
+ ];
38
+
39
+ /**
40
+ * Gets the type of a password character.
41
+ * @param {string} character The character.
42
+ * @returns {string} The character type.
43
+ */
44
+ const getCharacterType = (character) =>
45
+ characterTypes.find(
46
+ ({ pattern }) => pattern.test(character),
47
+ )?.type || 'symbol';
48
+
49
+ /**
50
+ * Gets a length-based password strength.
51
+ * @param {string[]} characters The password characters.
52
+ * @returns {number} The password strength.
53
+ */
54
+ const getLengthStrength = (characters) => {
55
+ const band = lengthBands.find(
56
+ ({ end }) => characters.length <= end,
57
+ ) || lengthBands.at(-1);
58
+ const score = Math.round(
59
+ $._map(
60
+ Math.min(characters.length, band.end),
61
+ band.start,
62
+ band.end,
63
+ band.min,
64
+ band.max,
65
+ ),
66
+ );
67
+ const diversity = new Set(
68
+ characters.map(getCharacterType),
69
+ ).size;
70
+
71
+ return Math.min(
72
+ score +
73
+ (diversity - 1) * 2,
74
+ band.max,
75
+ );
76
+ };
77
+
78
+ /**
79
+ * Gets the smallest repeated pattern.
80
+ * @param {string[]} characters The password characters.
81
+ * @returns {string[]|null} The repeated pattern.
82
+ */
83
+ const getRepeatedPattern = (characters) => {
84
+ const maxLength = Math.floor(characters.length / 2);
85
+
86
+ for (let length = 1; length <= maxLength; length++) {
87
+ if (
88
+ characters.length % length === 0 &&
89
+ characters.every(
90
+ (character, index) =>
91
+ character === characters[index % length],
92
+ )
93
+ ) {
94
+ return characters.slice(0, length);
95
+ }
96
+ }
97
+
98
+ return null;
99
+ };
100
+
101
+ /**
102
+ * Normalizes common password matching.
103
+ * @param {string} password The password.
104
+ * @returns {string} The normalized password.
105
+ */
106
+ const normalizePassword = (password) =>
107
+ String(password)
108
+ .normalize('NFKC')
109
+ .toLowerCase();
110
+
111
+ /**
112
+ * Normalizes common leet substitutions.
113
+ * @param {string} password The password.
114
+ * @returns {string} The normalized password.
115
+ */
116
+ const normalizeLeet = (password) =>
117
+ Array.from(
118
+ password,
119
+ (character) => leetCharacters[character] || character,
120
+ ).join('');
121
+
122
+ /**
123
+ * Gets normalized variants used to match common passwords.
124
+ * @param {string} password The normalized password.
125
+ * @returns {Set<string>} The password variants.
126
+ */
127
+ const getPasswordVariants = (password) => {
128
+ // Preserve digits for decoration stripping while resolving symbol substitutions.
129
+ const substituted = password.replace(/[^\p{L}\p{N}]/gu, normalizeLeet);
130
+ const variants = [password, substituted].flatMap((value) => {
131
+ const undecorated = value.replace(
132
+ /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu,
133
+ '',
134
+ );
135
+
136
+ return [
137
+ undecorated,
138
+ undecorated.replace(/^\d+|\d+$/g, ''),
139
+ ];
140
+ });
141
+
142
+ return new Set([
143
+ normalizeLeet(password),
144
+ ...variants,
145
+ ...variants.map(normalizeLeet),
146
+ ]);
147
+ };
148
+
149
+ /**
150
+ * Gets the score for a common password.
151
+ * @param {string} password The normalized password.
152
+ * @param {string[]} commonPasswords The common passwords.
153
+ * @returns {number|null} The common-password score.
154
+ */
155
+ const getCommonPasswordStrength = (password, commonPasswords) => {
156
+ const common = new Set(
157
+ commonPasswords.map(normalizePassword),
158
+ );
159
+
160
+ if (common.has(password)) {
161
+ return 0;
162
+ }
163
+
164
+ return common.isDisjointFrom(getPasswordVariants(password)) ? null : 5;
165
+ };
166
+
167
+ /**
168
+ * Checks whether characters form an ascending or descending sequence.
169
+ * @param {string[]} characters The password characters.
170
+ * @returns {boolean} Whether the characters form a sequence.
171
+ */
172
+ const isSequence = (characters) => {
173
+ const [first, second, third] = characters.map(
174
+ (character) => character.toLowerCase(),
175
+ );
176
+ const type = getCharacterType(first);
177
+ const direction = second.codePointAt(0) - first.codePointAt(0);
178
+
179
+ return ['lower', 'number'].includes(type) &&
180
+ getCharacterType(second) === type &&
181
+ getCharacterType(third) === type &&
182
+ Math.abs(direction) === 1 &&
183
+ third.codePointAt(0) - second.codePointAt(0) === direction;
184
+ };
185
+
186
+ /**
187
+ * Checks whether characters form a keyboard sequence.
188
+ * @param {string[]} characters The password characters.
189
+ * @returns {boolean} Whether the characters form a keyboard sequence.
190
+ */
191
+ const isKeyboardSequence = (characters) => {
192
+ const sequence = characters.join('').toLowerCase();
193
+
194
+ return keyboardSequences.some(
195
+ (keyboardSequence) => keyboardSequence.includes(sequence),
196
+ );
197
+ };
198
+
199
+ /**
200
+ * Checks whether characters form a predictable pattern.
201
+ * @param {string[]} characters The password characters.
202
+ * @returns {boolean} Whether the characters form a predictable pattern.
203
+ */
204
+ const isPredictable = (characters) =>
205
+ new Set(characters).size === 1 ||
206
+ isSequence(characters) ||
207
+ isKeyboardSequence(characters);
208
+
209
+ /**
210
+ * Gets the number of characters in predictable patterns.
211
+ * @param {string[]} characters The password characters.
212
+ * @returns {number} The number of predictable characters.
213
+ */
214
+ const getPredictableCount = (characters) => {
215
+ const indices = new Set();
216
+
217
+ for (let i = 0; i + 2 < characters.length; i++) {
218
+ if (isPredictable(characters.slice(i, i + 3))) {
219
+ indices.add(i);
220
+ indices.add(i + 1);
221
+ indices.add(i + 2);
222
+ }
223
+ }
224
+
225
+ return indices.size;
226
+ };
227
+
228
+ /**
229
+ * Calculates the strength of a password.
230
+ * @param {string} password The password.
231
+ * @param {string[]} [commonPasswords=[]] Passwords to score as very weak.
232
+ * @returns {number} The password strength, from 0 to 100.
233
+ */
234
+ export const getStrength = (password, commonPasswords = []) => {
235
+ const normalizedPassword = password.normalize('NFKC');
236
+ const characters = Array.from(normalizedPassword);
237
+
238
+ if (!characters.length) {
239
+ return 0;
240
+ }
241
+
242
+ const commonStrength = getCommonPasswordStrength(
243
+ normalizedPassword.toLowerCase(),
244
+ commonPasswords,
245
+ );
246
+
247
+ if (commonStrength !== null) {
248
+ return commonStrength;
249
+ }
250
+
251
+ const score = getLengthStrength(characters);
252
+ const repeatedPattern = getRepeatedPattern(characters);
253
+
254
+ if (repeatedPattern?.length === 1) {
255
+ return 0;
256
+ }
257
+
258
+ if (repeatedPattern) {
259
+ return Math.min(
260
+ score,
261
+ getLengthStrength(repeatedPattern),
262
+ 15,
263
+ );
264
+ }
265
+
266
+ const predictableCount = getPredictableCount(characters);
267
+
268
+ return $._clamp(
269
+ score - predictableCount,
270
+ 0,
271
+ predictableCount === characters.length ? 15 : 100,
272
+ );
273
+ };
@@ -1,10 +0,0 @@
1
- import $ from '@fr0st/query';
2
-
3
- /**
4
- * Attach events for the PasswordStrength.
5
- */
6
- export function _events() {
7
- $.addEvent(this._node, 'input.ui.passwordstrength', (_) => {
8
- this._refresh();
9
- });
10
- };
@@ -1,33 +0,0 @@
1
- import $ from '@fr0st/query';
2
-
3
- /**
4
- * Refresh the password strength.
5
- */
6
- export function _refresh() {
7
- const strength = this.getStrength();
8
-
9
- let nextLevel;
10
- for (const level of this._options.levels) {
11
- if (strength < level.score) {
12
- break;
13
- }
14
-
15
- nextLevel = level;
16
- }
17
-
18
- $.setStyle(this._progressBar, { width: `${strength}%` });
19
- $.setAttribute(this._progressBar, {
20
- 'class': this.constructor.classes.progressBar,
21
- 'aria-valuenow': strength,
22
- });
23
-
24
- $.addClass(this._progressBar, nextLevel.class);
25
-
26
- if (this._options.striped) {
27
- $.addClass(this._progressBar, this.constructor.classes.progressBarStriped);
28
- }
29
-
30
- if (nextLevel.text) {
31
- $.setText(this._progressBar, nextLevel.text);
32
- }
33
- };
@@ -1,29 +0,0 @@
1
- import $ from '@fr0st/query';
2
- import { generateId } from '@fr0st/ui';
3
-
4
- /**
5
- * Render the password strength element.
6
- */
7
- export function _render() {
8
- this._progress = $.create('div', {
9
- class: this.constructor.classes.progress,
10
- });
11
-
12
- const id = generateId('password-strength');
13
-
14
- this._progressBar = $.create('div', {
15
- attributes: {
16
- 'id': id,
17
- 'role': 'progressbar',
18
- 'aria-valuemin': 0,
19
- 'aria-valuemax': 100,
20
- },
21
- });
22
-
23
- $.append(this._progress, this._progressBar);
24
- $.append(this._container, this._progress);
25
-
26
- $.setAttribute(this._node, {
27
- 'aria-describedby': id,
28
- });
29
- };