feat: add password strength validation (#1561)

* feat: add password strength validation

* refactor: rename password state variables for consistency

* chore: enforce only minimum length check for password(create account)

* chore: add translation for enforced password suggestion text
This commit is contained in:
rajk93 2025-06-26 11:26:55 +05:30 committed by GitHub
parent 3d73f688c3
commit 26b2eebb92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 222 additions and 20 deletions

View File

@ -40,6 +40,9 @@
"@polkadot/ui-settings": "^3.15.1",
"@polkadot/util": "^13.5.2",
"@polkadot/util-crypto": "^13.5.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"file-saver": "^2.0.5",
"i18next": "^23.7.11",
"react": "^18.2.0",

View File

@ -18,6 +18,7 @@ interface Props {
isReadOnly?: boolean;
label: string;
onBlur?: () => void;
onFocus?: () => void;
onChange?: (value: string) => void;
onEnter?: () => void;
placeholder?: string;
@ -26,7 +27,7 @@ interface Props {
withoutMargin?: boolean;
}
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, onFocus, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
const [isCapsLock, setIsCapsLock] = useState(false);
const { t } = useTranslation();
@ -65,6 +66,7 @@ function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused
disabled={disabled}
onBlur={onBlur}
onChange={_onChange}
onFocus={onFocus}
onKeyPress={_checkKey}
placeholder={placeholder}
readOnly={isReadOnly}

View File

@ -0,0 +1,74 @@
// Copyright 2019-2025 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PasswordStrength } from '../util/passwordValidation.js';
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
passwordStrength: PasswordStrength;
}
function PasswordStrengthIndicator ({ className, passwordStrength }: Props): React.ReactElement<Props> {
const { feedback, score } = passwordStrength;
const strengthColors = [
'#ff4444', // Very Weak - Red
'#ffa700', // Weak - Orange
'#ffeb3b', // Medium - Yellow
'#00C853', // Strong - Light Green
'#00695C' // Very Strong - Dark Green
];
return (
<div className={className}>
<div className='strength-meter'>
{[0, 1, 2, 3, 4].map((index) => (
<div
className={`strength-segment ${index <= score ? 'active' : ''}`}
key={index}
style={{
backgroundColor: index <= score ? strengthColors[score] : '#e0e0e0'
}}
/>
))}
</div>
{feedback.suggestions.length > 0 && (
<ul className='suggestions'>
{feedback.suggestions.map((suggestion, index) => (
<li key={index}>{suggestion}</li>
))}
</ul>
)}
</div>
);
}
export default styled(PasswordStrengthIndicator)`
.strength-meter {
margin-top: 0.5rem;
display: flex;
margin-bottom: 0.5rem;
}
.strength-segment {
flex: 1;
height: 4px;
margin: 0 2px;
}
.strength-label {
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.suggestions {
margin: 0.5rem 0;
padding-left: 1.5rem;
font-size: 0.85rem;
color: var(--warning-color);
}
`;

View File

@ -13,6 +13,8 @@ interface BasicProps {
isError?: boolean;
value?: string | null;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
}
type Props<T extends BasicProps> = T & {

View File

@ -29,6 +29,7 @@ export { default as MenuDivider } from './MenuDivider.js';
export { default as MenuItem } from './MenuItem.js';
export { default as MnemonicSeed } from './MnemonicSeed.js';
export { default as NextStepButton } from './NextStepButton.js';
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator.js';
export { default as RemoveAuth } from './RemoveAuth.js';
export { default as Spinner } from './Spinner.js';
export { default as Svg } from './Svg.js';

View File

@ -3,11 +3,12 @@
import type { Validator } from '../util/validators.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { InputWithLabel, ValidatedInput } from '../components/index.js';
import { InputWithLabel, PasswordStrengthIndicator, ValidatedInput } from '../components/index.js';
import { useTranslation } from '../hooks/index.js';
import { allOf, isNotShorterThan, isSameAs } from '../util/validators.js';
import { validatePasswordStrength } from '../util/passwordValidation.js';
import { allOf, isNotShorterThan, isSameAs, Result } from '../util/validators.js';
interface Props {
isFocussed?: boolean;
@ -18,17 +19,38 @@ const MIN_LENGTH = 6;
export default function Password ({ isFocussed, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [pass1, setPass1] = useState<string | null>(null);
const [pass2, setPass2] = useState<string | null>(null);
const isFirstPasswordValid = useMemo(() => isNotShorterThan(MIN_LENGTH, t('Password is too short')), [t]);
const [password, setPassword] = useState<string | null>(null);
const [confirmPassword, setConfirmPassword] = useState<string | null>(null);
const [passwordStrength, setPasswordStrength] = useState(validatePasswordStrength('', t));
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
const [isPasswordResultInit, setIsPasswordResultInit] = useState(false);
const handlePasswordFocus = useCallback((): void => setIsPasswordFocused(true), []);
const handlePasswordBlur = useCallback((): void => setIsPasswordFocused(false), []);
// Primary password validation using zxcvbn
const isFirstPasswordValid = useCallback<Validator<string>>((password) => {
setIsPasswordResultInit(true);
const strength = validatePasswordStrength(password, t);
setPasswordStrength(strength);
if (password.length < MIN_LENGTH) {
return Promise.resolve(Result.error(t('Password is too short')));
}
return Promise.resolve(Result.ok<string>(password));
}, [t]);
const isSecondPasswordValid = useCallback((firstPassword: string): Validator<string> => allOf(
isNotShorterThan(MIN_LENGTH, t('Password is too short')),
isSameAs(firstPassword, t('Passwords do not match'))
), [t]);
useEffect((): void => {
onChange(pass1 && pass2 ? pass1 : null);
}, [onChange, pass1, pass2]);
onChange(password && confirmPassword ? password : null);
}, [onChange, password, confirmPassword]);
return (
<>
@ -37,18 +59,21 @@ export default function Password ({ isFocussed, onChange }: Props): React.ReactE
data-input-password
isFocused={isFocussed}
label={t('A new password for this account')}
onValidatedChange={setPass1}
onBlur={handlePasswordBlur}
onFocus={handlePasswordFocus}
onValidatedChange={setPassword}
type='password'
validator={isFirstPasswordValid}
/>
{pass1 && (
{isPasswordFocused && isPasswordResultInit && <PasswordStrengthIndicator passwordStrength={passwordStrength} />}
{password && (
<ValidatedInput
component={InputWithLabel}
data-input-repeat-password
label={t('Repeat password for verification')}
onValidatedChange={setPass2}
onValidatedChange={setConfirmPassword}
type='password'
validator={isSecondPasswordValid(pass1)}
validator={isSecondPasswordValid(password)}
/>
)}
</>

View File

@ -0,0 +1,54 @@
// Copyright 2019-2025 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { TFunction } from '../hooks/useTranslation.js';
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
// Configure zxcvbn with English dictionary and common patterns
zxcvbnOptions.setOptions({
dictionary: {
...zxcvbnCommonPackage.dictionary, // common words across languages
...zxcvbnEnPackage.dictionary // english-specific dictionary
},
graphs: zxcvbnCommonPackage.adjacencyGraphs, // keyboard patterns
translations: zxcvbnEnPackage.translations // language translations for feedback messages
});
export interface PasswordStrength {
feedback: {
warning: string;
suggestions: string[];
};
score: number; // 0-4 (0: very weak, 4: very strong)
}
const MIN_LENGTH = 6; // Minimum password length requirement
export function validatePasswordStrength (password: string, t: TFunction): PasswordStrength {
const result = zxcvbn(password);
// First check: Minimum length requirement
if (password.length < MIN_LENGTH) {
return {
feedback: {
suggestions: [t('Password must be at least {{length}} characters long', { replace: { length: MIN_LENGTH } })],
warning: t('Password is too short')
},
score: 0
};
}
// Combine zxcvbn suggestions with our custom ones
const suggestions = (result.feedback.suggestions || []).map((suggestion) => t(suggestion));
return {
feedback: {
suggestions,
warning: result.feedback.warning ? t(result.feedback.warning) : ''
},
score: result.score
};
}

View File

@ -158,5 +158,6 @@
"Export all accounts": "সমস্ত অ্যাকাউন্ট এক্সপোর্ট করুন",
"All account": "সমস্ত অ্যাকাউন্ট",
"password for encrypting all accounts": "সকল অ্যাকাউন্ট এনক্রিপ্ট করার জন্য পাসওয়ার্ড",
"I want to export all my accounts": "আমি আমার সকল অ্যাকাউন্ট এক্সপোর্ট করতে চাই"
"I want to export all my accounts": "আমি আমার সকল অ্যাকাউন্ট এক্সপোর্ট করতে চাই",
"Password must be at least {{length}} characters long": "পাসওয়ার্ড কমপক্ষে {{length}} অক্ষরের হতে হবে"
}

View File

@ -182,5 +182,7 @@
"Ledger App": "",
"Don't ask again": "",
"Previous": "",
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": ""
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
"Password is not strong enough": "",
"Password must be at least {{length}} characters long": ""
}

View File

@ -153,5 +153,6 @@
"Import Account": "Importer",
"Sign on Ledger": "Signer sur Ledger",
"Allow use on any chain": "Toutes les blockchains",
"Select network": "Sélectionner un réseau"
"Select network": "Sélectionner un réseau",
"Password must be at least {{length}} characters long": "Le mot de passe doit contenir au moins {{length}} caractères"
}

View File

@ -158,5 +158,6 @@
"Export all accounts": "Eksportuj wszystkie konta",
"All account": "Wszystkie konta",
"password for encrypting all accounts": "hasło do szyfrowania wszystkich kont",
"I want to export all my accounts": "Chcę wyeksportować wszystkie moje konta"
"I want to export all my accounts": "Chcę wyeksportować wszystkie moje konta",
"Password must be at least {{length}} characters long": "Hasło musi mieć co najmniej {{length}} znaków"
}

View File

@ -158,5 +158,6 @@
"Export all accounts": "ส่งออกทุกบัญชี",
"All account": "ทุกบัญชี",
"password for encrypting all accounts": "รหัสผ่านสำหรับการเข้ารหัสทุกบัญชี",
"I want to export all my accounts": "ฉันอยากจะนำออกทุกบัญชี"
"I want to export all my accounts": "ฉันอยากจะนำออกทุกบัญชี",
"Password must be at least {{length}} characters long": "รหัสผ่านต้องมีอย่างน้อย {{length}} ตัวอักษร"
}

View File

@ -158,7 +158,8 @@
"Export all accounts": "تماماکاؤنٹسبرآمدکریں۔",
"All account": "تماماکاؤنٹ",
"password for encrypting all accounts": "تماماکاؤنٹسکوخفیہکرنےکےلیےپاسورڈ۔",
"I want to export all my accounts": "میںاپنےتماماکاؤنٹسبرآمدکرناچاہتاہوں۔"
"I want to export all my accounts": "میںاپنےتماماکاؤنٹسبرآمدکرناچاہتاہوں۔",
"Password must be at least {{length}} characters long": "پاس ورڈ کم از کم {{length}} حروف کا ہونا چاہیے۔"
}

View File

@ -103,5 +103,6 @@
"Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.": "请写下你的钱包助记词,放在安全的地方。这个助记词可以用来恢复你的钱包。小心保管,以免失去你的资产。",
"You are exporting your account. Keep it safe and don't share it with anyone.": "您正在导出帐户。保管好它,不要与任何人分享。",
"Manage Website Access": "管理网站访问",
"Notifications": "通知"
"Notifications": "通知",
"Password must be at least {{length}} characters long": "密码必须至少{{length}}个字符"
}

View File

@ -1063,6 +1063,9 @@ __metadata:
"@types/react-router-dom": "npm:^5.3.3"
"@types/sinon-chrome": "npm:^2.2.15"
"@wojtekmaj/enzyme-adapter-react-17": "npm:^0.8.0"
"@zxcvbn-ts/core": "npm:^3.0.4"
"@zxcvbn-ts/language-common": "npm:^3.0.4"
"@zxcvbn-ts/language-en": "npm:^3.0.2"
enzyme: "npm:^3.11.0"
enzyme-adapter-react-16: "npm:^1.15.7"
file-saver: "npm:^2.0.5"
@ -2875,6 +2878,29 @@ __metadata:
languageName: node
linkType: hard
"@zxcvbn-ts/core@npm:^3.0.4":
version: 3.0.4
resolution: "@zxcvbn-ts/core@npm:3.0.4"
dependencies:
fastest-levenshtein: "npm:1.0.16"
checksum: 10/9036257b0fdc317832a1942769bf5816dedf5ac2379ad6e4d27ef29a87c5e28e8088788fc42efa15510a57f067f4bfce2843342fa248f3598de4e75fb7ae6fbf
languageName: node
linkType: hard
"@zxcvbn-ts/language-common@npm:^3.0.4":
version: 3.0.4
resolution: "@zxcvbn-ts/language-common@npm:3.0.4"
checksum: 10/90a00418ef0586da77032e59aff45a2c8e6325b21e2930b6c0fdcaba5f9d6c473aac34cfe443d58994dab05c347819fbccfaa310d65b353319ec4f5b63d36c9f
languageName: node
linkType: hard
"@zxcvbn-ts/language-en@npm:^3.0.2":
version: 3.0.2
resolution: "@zxcvbn-ts/language-en@npm:3.0.2"
checksum: 10/f627a07e4c635dd3f84f0738d480882ab67fa77c114bb7e63343162cbd442dbed6325cd99ef2eff578ac61984f7a7031051f335bf9313e1ad6737ef2e3c6cd25
languageName: node
linkType: hard
"@zxing/browser@npm:^0.1.5":
version: 0.1.5
resolution: "@zxing/browser@npm:0.1.5"
@ -6816,6 +6842,13 @@ __metadata:
languageName: node
linkType: hard
"fastest-levenshtein@npm:1.0.16":
version: 1.0.16
resolution: "fastest-levenshtein@npm:1.0.16"
checksum: 10/ee85d33b5cef592033f70e1c13ae8624055950b4eb832435099cd56aa313d7f251b873bedbc06a517adfaff7b31756d139535991e2406967438e03a1bf1b008e
languageName: node
linkType: hard
"fastest-levenshtein@npm:^1.0.12":
version: 1.0.12
resolution: "fastest-levenshtein@npm:1.0.12"