From 91ffbadcaec8d18d7f5deddecd232e385699aa03 Mon Sep 17 00:00:00 2001 From: hexdev Date: Tue, 18 Aug 2026 12:41:37 +0700 Subject: [PATCH] feat: browser-side security check --- package-lock.json | 16 +++++++++++++++- package.json | 4 +++- src/lib/password.test.ts | 16 ++++++++++++++++ src/lib/password.ts | 11 +++++++++++ src/pages/LoginPage.test.tsx | 16 ++++++++++++++-- src/pages/LoginPage.tsx | 5 +++-- tsconfig.tsbuildinfo | 2 +- 7 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 src/lib/password.test.ts create mode 100644 src/lib/password.ts diff --git a/package-lock.json b/package-lock.json index 60c569e..59c4a15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "@types/qrcode": "^1.5.6", + "@types/zxcvbn": "^4.4.5", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^6.28.0", - "zustand": "^5.0.2" + "zustand": "^5.0.2", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", @@ -1886,6 +1888,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/zxcvbn": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz", + "integrity": "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.66.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", @@ -8032,6 +8040,12 @@ "optional": true } } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 37cd5d5..70d0884 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ }, "dependencies": { "@types/qrcode": "^1.5.6", + "@types/zxcvbn": "^4.4.5", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^6.28.0", - "zustand": "^5.0.2" + "zustand": "^5.0.2", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/src/lib/password.test.ts b/src/lib/password.test.ts new file mode 100644 index 0000000..b1f35c6 --- /dev/null +++ b/src/lib/password.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isPasswordStrong } from './password'; + +describe('isPasswordStrong', () => { + it('rejects passwords shorter than eight characters', () => { + expect(isPasswordStrong('A1!abcd')).toBe(false); + }); + + it('rejects obvious passwords with a zxcvbn score of two or below', () => { + expect(isPasswordStrong('password1')).toBe(false); + }); + + it('accepts passwords with a zxcvbn score above two', () => { + expect(isPasswordStrong('correct horse battery staple')).toBe(true); + }); +}); diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..a86521b --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,11 @@ +import zxcvbn from 'zxcvbn'; + +const MIN_PASSWORD_LENGTH = 8; +const MIN_PASSWORD_SCORE = 3; + +export function isPasswordStrong(password: string): boolean { + return password.length >= MIN_PASSWORD_LENGTH && zxcvbn(password).score >= MIN_PASSWORD_SCORE; +} + +export const passwordStrengthMessage = + 'Пароль должен быть не короче 8 символов и не состоять из очевидных сочетаний.'; diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx index d906066..1e51131 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -99,19 +99,31 @@ describe('LoginPage', () => { renderLogin(); await user.click(screen.getByRole('tab', { name: /Регистрация/i })); await user.type(screen.getByLabelText('Имя пользователя'), 'alice'); - await user.type(screen.getByLabelText('Пароль'), 'secret'); + await user.type(screen.getByLabelText('Пароль'), 'correct horse battery staple'); await user.click(screen.getByRole('button', { name: /создать аккаунт/i })); await waitFor(() => expect(api.signup).toHaveBeenCalledWith({ provider: 'credentials', username: 'alice', - password: 'secret', + password: 'correct horse battery staple', referal_code: 'friend-code', }), ); }); + it('does not submit a weak password during signup', async () => { + const user = userEvent.setup(); + renderLogin(); + await user.click(screen.getByRole('tab', { name: /Регистрация/i })); + await user.type(screen.getByLabelText('Имя пользователя'), 'alice'); + await user.type(screen.getByLabelText('Пароль'), 'password1'); + await user.click(screen.getByRole('button', { name: /создать аккаунт/i })); + + expect(await screen.findByText(/не короче 8 символов/i)).toBeInTheDocument(); + expect(api.signup).not.toHaveBeenCalled(); + }); + it('shows backend error on login failure', async () => { const user = userEvent.setup(); vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401)); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index e0c9c6d..76b7a3d 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -7,6 +7,7 @@ import { Emblem } from '../components/Emblem'; import { Logo } from '../components/Logo'; import { Reveal } from '../components/Reveal'; import { getReferralCode } from '../lib/referral'; +import { isPasswordStrong, passwordStrengthMessage } from '../lib/password'; type Mode = 'signin' | 'signup'; @@ -75,8 +76,8 @@ export function LoginPage() { setStatusMsg('Имя пользователя и пароль обязательны.', 'error'); return; } - if (mode === 'signup' && password.length < 4) { - setStatusMsg('Пароль слишком короткий (минимум 4 символа).', 'error'); + if (mode === 'signup' && !isPasswordStrong(password)) { + setStatusMsg(passwordStrengthMessage, 'error'); return; } setSubmitting(true); diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 4bf7d37..314c360 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/ClientLinksModal.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/ClientLinksModal.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/password.test.ts","./src/lib/password.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"} \ No newline at end of file