From 26b2eebb92dc3d40eb185ffb2dc33b7b4c8f8be6 Mon Sep 17 00:00:00 2001 From: rajk93 Date: Thu, 26 Jun 2025 11:26:55 +0530 Subject: [PATCH] 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 --- packages/extension-ui/package.json | 3 + .../src/components/InputWithLabel.tsx | 4 +- .../components/PasswordStrengthIndicator.tsx | 74 +++++++++++++++++++ .../src/components/ValidatedInput.tsx | 2 + packages/extension-ui/src/components/index.ts | 1 + .../extension-ui/src/partials/Password.tsx | 49 +++++++++--- .../src/util/passwordValidation.ts | 54 ++++++++++++++ .../public/locales/bn/translation.json | 3 +- .../public/locales/en/translation.json | 4 +- .../public/locales/fr/translation.json | 3 +- .../public/locales/pl/translation.json | 3 +- .../public/locales/th/translation.json | 3 +- .../public/locales/ur/translation.json | 3 +- .../public/locales/zh/translation.json | 3 +- yarn.lock | 33 +++++++++ 15 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 packages/extension-ui/src/components/PasswordStrengthIndicator.tsx create mode 100644 packages/extension-ui/src/util/passwordValidation.ts diff --git a/packages/extension-ui/package.json b/packages/extension-ui/package.json index c1e6eeb3..ca13e3c4 100644 --- a/packages/extension-ui/package.json +++ b/packages/extension-ui/package.json @@ -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", diff --git a/packages/extension-ui/src/components/InputWithLabel.tsx b/packages/extension-ui/src/components/InputWithLabel.tsx index bb83d3d4..941beaaf 100644 --- a/packages/extension-ui/src/components/InputWithLabel.tsx +++ b/packages/extension-ui/src/components/InputWithLabel.tsx @@ -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 { +function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, onFocus, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement { 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} diff --git a/packages/extension-ui/src/components/PasswordStrengthIndicator.tsx b/packages/extension-ui/src/components/PasswordStrengthIndicator.tsx new file mode 100644 index 00000000..fc16a56e --- /dev/null +++ b/packages/extension-ui/src/components/PasswordStrengthIndicator.tsx @@ -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 { + 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 ( +
+
+ {[0, 1, 2, 3, 4].map((index) => ( +
+ ))} +
+ {feedback.suggestions.length > 0 && ( +
    + {feedback.suggestions.map((suggestion, index) => ( +
  • {suggestion}
  • + ))} +
+ )} +
+ ); +} + +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); + } +`; diff --git a/packages/extension-ui/src/components/ValidatedInput.tsx b/packages/extension-ui/src/components/ValidatedInput.tsx index 43ff2999..81e9716a 100644 --- a/packages/extension-ui/src/components/ValidatedInput.tsx +++ b/packages/extension-ui/src/components/ValidatedInput.tsx @@ -13,6 +13,8 @@ interface BasicProps { isError?: boolean; value?: string | null; onChange?: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; } type Props = T & { diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts index 44edbc48..657a6c8a 100644 --- a/packages/extension-ui/src/components/index.ts +++ b/packages/extension-ui/src/components/index.ts @@ -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'; diff --git a/packages/extension-ui/src/partials/Password.tsx b/packages/extension-ui/src/partials/Password.tsx index a01c8a8f..9d92ac66 100644 --- a/packages/extension-ui/src/partials/Password.tsx +++ b/packages/extension-ui/src/partials/Password.tsx @@ -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 { const { t } = useTranslation(); - const [pass1, setPass1] = useState(null); - const [pass2, setPass2] = useState(null); - const isFirstPasswordValid = useMemo(() => isNotShorterThan(MIN_LENGTH, t('Password is too short')), [t]); + const [password, setPassword] = useState(null); + const [confirmPassword, setConfirmPassword] = useState(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>((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(password)); + }, [t]); + const isSecondPasswordValid = useCallback((firstPassword: string): Validator => 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 && } + {password && ( )} diff --git a/packages/extension-ui/src/util/passwordValidation.ts b/packages/extension-ui/src/util/passwordValidation.ts new file mode 100644 index 00000000..2c4c4eae --- /dev/null +++ b/packages/extension-ui/src/util/passwordValidation.ts @@ -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 + }; +} diff --git a/packages/extension/public/locales/bn/translation.json b/packages/extension/public/locales/bn/translation.json index c0cfd1c1..81aa081b 100644 --- a/packages/extension/public/locales/bn/translation.json +++ b/packages/extension/public/locales/bn/translation.json @@ -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}} অক্ষরের হতে হবে" } diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index 6c059a47..6d536776 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -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": "" } diff --git a/packages/extension/public/locales/fr/translation.json b/packages/extension/public/locales/fr/translation.json index 2cc135f0..2a067a4c 100644 --- a/packages/extension/public/locales/fr/translation.json +++ b/packages/extension/public/locales/fr/translation.json @@ -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" } diff --git a/packages/extension/public/locales/pl/translation.json b/packages/extension/public/locales/pl/translation.json index cfbae4e1..553a810e 100644 --- a/packages/extension/public/locales/pl/translation.json +++ b/packages/extension/public/locales/pl/translation.json @@ -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" } diff --git a/packages/extension/public/locales/th/translation.json b/packages/extension/public/locales/th/translation.json index 08c29b31..bda0f930 100644 --- a/packages/extension/public/locales/th/translation.json +++ b/packages/extension/public/locales/th/translation.json @@ -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}} ตัวอักษร" } diff --git a/packages/extension/public/locales/ur/translation.json b/packages/extension/public/locales/ur/translation.json index b4d49661..3a2e6e24 100644 --- a/packages/extension/public/locales/ur/translation.json +++ b/packages/extension/public/locales/ur/translation.json @@ -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}} حروف کا ہونا چاہیے۔" } diff --git a/packages/extension/public/locales/zh/translation.json b/packages/extension/public/locales/zh/translation.json index e4cb1870..aac61d09 100644 --- a/packages/extension/public/locales/zh/translation.json +++ b/packages/extension/public/locales/zh/translation.json @@ -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}}个字符" } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8ea1e4a2..e662e9bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"