feat: browser-side security check
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -9,11 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
@@ -1886,6 +1888,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.66.0",
|
"version": "8.66.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
||||||
@@ -8032,6 +8040,12 @@
|
|||||||
"optional": true
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
|||||||
16
src/lib/password.test.ts
Normal file
16
src/lib/password.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/lib/password.ts
Normal file
11
src/lib/password.ts
Normal file
@@ -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 символов и не состоять из очевидных сочетаний.';
|
||||||
@@ -99,19 +99,31 @@ describe('LoginPage', () => {
|
|||||||
renderLogin();
|
renderLogin();
|
||||||
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||||
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
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 user.click(screen.getByRole('button', { name: /создать аккаунт/i }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.signup).toHaveBeenCalledWith({
|
expect(api.signup).toHaveBeenCalledWith({
|
||||||
provider: 'credentials',
|
provider: 'credentials',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'secret',
|
password: 'correct horse battery staple',
|
||||||
referal_code: 'friend-code',
|
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 () => {
|
it('shows backend error on login failure', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Emblem } from '../components/Emblem';
|
|||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { getReferralCode } from '../lib/referral';
|
import { getReferralCode } from '../lib/referral';
|
||||||
|
import { isPasswordStrong, passwordStrengthMessage } from '../lib/password';
|
||||||
|
|
||||||
type Mode = 'signin' | 'signup';
|
type Mode = 'signin' | 'signup';
|
||||||
|
|
||||||
@@ -75,8 +76,8 @@ export function LoginPage() {
|
|||||||
setStatusMsg('Имя пользователя и пароль обязательны.', 'error');
|
setStatusMsg('Имя пользователя и пароль обязательны.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'signup' && password.length < 4) {
|
if (mode === 'signup' && !isPasswordStrong(password)) {
|
||||||
setStatusMsg('Пароль слишком короткий (минимум 4 символа).', 'error');
|
setStatusMsg(passwordStrengthMessage, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
Reference in New Issue
Block a user