feat: browser-side security check
This commit is contained in:
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();
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user