feat: browser-side security check

This commit is contained in:
2026-08-18 12:41:37 +07:00
parent 33d35d91e6
commit 91ffbadcae
7 changed files with 63 additions and 7 deletions

16
src/lib/password.test.ts Normal file
View 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
View 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 символов и не состоять из очевидных сочетаний.';

View File

@@ -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));

View File

@@ -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);