From ffc5aaa84abea8d0f66516646b9dae8a8fc5aeeb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 16 Oct 2026 09:42:07 +0300 Subject: [PATCH] fix: align password validation with backend ASCII policy (WealthLedger/backend#60) Register requires 8-72 printable ASCII characters; login only caps the password at 72 bytes and no longer enforces a minimum, so accounts created under the previous policy can still sign in. OpenAPI snapshot is delivered from backend and schema.d.ts regenerated with npm run openapi:generate (CredentialsDto is now RegisterDto and LoginDto). --- src/features/auth/model/auth.schema.test.ts | 55 +++++++++++++++++++ src/features/auth/model/auth.schema.ts | 52 +++++++++++++----- .../auth/ui/AuthForm/AuthForm.test.tsx | 35 ++++++++++++ src/shared/api/generated/schema.d.ts | 20 +++++-- src/shared/api/spec/openapi.json | 28 ++++++++-- src/shared/i18n/locales/ru.ts | 3 +- 6 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 src/features/auth/model/auth.schema.test.ts diff --git a/src/features/auth/model/auth.schema.test.ts b/src/features/auth/model/auth.schema.test.ts new file mode 100644 index 0000000..1e71982 --- /dev/null +++ b/src/features/auth/model/auth.schema.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { loginSchema, registerSchema } from "./auth.schema"; + +const email = "user@example.com"; + +function canLogIn(password: string) { + return loginSchema.safeParse({ email, password }).success; +} + +function canRegister(password: string, confirmation = password) { + return registerSchema.safeParse({ + email, + password, + passwordConfirmation: confirmation, + }).success; +} + +// Register policy: 8..72 printable ASCII (bcrypt reads 72 bytes, so non-ASCII could be +// truncated). Login stays lenient so accounts created under the old policy still sign in. +describe("auth password policy", () => { + it.each([ + ["7 chars", "a".repeat(7), false], + ["8 chars", "a".repeat(8), true], + ["72 chars", "a".repeat(72), true], + ["73 chars", "a".repeat(73), false], + ["printable symbols and space", "Aa1 !~\"#$%&'()*+,-./:;<=>?@", true], + ["Cyrillic, 8+ chars", "пароль-пароль", false], + ["emoji", "passw😀rd", false], + ["tab", "pass\tword1", false], + ["newline", "password1\n", false], + ["DEL", "password\u{7F}1", false], + ])("register: %s", (_, password, accepted) => { + expect(canRegister(password)).toBe(accepted); + }); + + it("register compares the raw password with its confirmation", () => { + const padded = " spaced pass "; + + expect(canRegister(padded)).toBe(true); + expect(canRegister(padded, padded.trim())).toBe(false); + }); + + it.each([ + ["legacy Cyrillic password", "пароль-пароль", true], + ["short legacy password", "short", true], + ["72 bytes", "a".repeat(72), true], + ["73 bytes", "a".repeat(73), false], + ["36 Cyrillic letters = 72 bytes", "я".repeat(36), true], + ["37 Cyrillic letters = 74 bytes", "я".repeat(37), false], + ["empty", "", false], + ])("login: %s", (_, password, accepted) => { + expect(canLogIn(password)).toBe(accepted); + }); +}); diff --git a/src/features/auth/model/auth.schema.ts b/src/features/auth/model/auth.schema.ts index a602a77..4df28c7 100644 --- a/src/features/auth/model/auth.schema.ts +++ b/src/features/auth/model/auth.schema.ts @@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2; const ASCII_SPACE = 32; const ASCII_DELETE = 127; const MAX_EMAIL_LENGTH = 254; -const MIN_PASSWORD_BYTES = 12; +const MIN_PASSWORD_LENGTH = 8; const MAX_PASSWORD_BYTES = 72; +// Printable ASCII keeps characters == bytes, within the bcrypt 72-byte limit. +const ASCII_TILDE = 126; function isEmailCharacterInvalid(character: string): boolean { const codePoint = character.codePointAt(0); @@ -20,6 +22,14 @@ function isEmailCharacterInvalid(character: string): boolean { ); } +function isPrintableAscii(value: string): boolean { + return value.split("").every((character) => { + const code = character.codePointAt(0) ?? 0; + + return code >= ASCII_SPACE && code <= ASCII_TILDE; + }); +} + const email = z .string() .trim() @@ -33,28 +43,42 @@ const email = z { error: validationMessage("auth.validation.invalidEmail") }, ); -const password = z +// Login stays lenient: accounts created under an older policy must still sign in. +const loginPassword = z .string() + .min(1) .refine( - value => - new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES - && new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, - { error: validationMessage("auth.validation.invalidPasswordLength") }, + value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, + { error: validationMessage("auth.validation.passwordTooLong") }, ); +const registerPassword = z + .string() + .min(MIN_PASSWORD_LENGTH, { + error: validationMessage("auth.validation.invalidPasswordLength"), + }) + .max(MAX_PASSWORD_BYTES, { + error: validationMessage("auth.validation.invalidPasswordLength"), + }) + .refine(isPrintableAscii, { + error: validationMessage("auth.validation.invalidPasswordLength"), + }); + const loginSchema = z.object({ email, - password, + password: loginPassword, passwordConfirmation: z.string().optional(), }); -const registerSchema = loginSchema.refine( - value => value.password === value.passwordConfirmation, - { - error: validationMessage("auth.validation.passwordsDoNotMatch"), - path: ["passwordConfirmation"], - }, -); +const registerSchema = loginSchema + .extend({ password: registerPassword }) + .refine( + value => value.password === value.passwordConfirmation, + { + error: validationMessage("auth.validation.passwordsDoNotMatch"), + path: ["passwordConfirmation"], + }, + ); type AuthFormValues = z.input; diff --git a/src/features/auth/ui/AuthForm/AuthForm.test.tsx b/src/features/auth/ui/AuthForm/AuthForm.test.tsx index 71daaf3..4c73b9c 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.test.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.test.tsx @@ -70,4 +70,39 @@ describe("AuthForm", () => { resolve(); await screen.findByRole("link", { name: linkLabel }); }); + + // Register policy is 8..72 printable ASCII; login accepts legacy passwords. + it.each([ + ["register", "Регистрация", "пароль-пароль", false], + ["register", "Регистрация", "passw😀rd", false], + ["register", "Регистрация", "a".repeat(7), false], + ["register", "Регистрация", "a".repeat(8), true], + ["register", "Регистрация", "a".repeat(72), true], + ["register", "Регистрация", "a".repeat(73), false], + ["login", "Вход", "пароль-пароль", true], + ])("%s submits password %j: %s", async (mode, submitLabel, password, submitted) => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + + render( + + + + + , + ); + + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); + fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: password } }); + + if (mode === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { target: { value: password } }); + } + + fireEvent.click(screen.getByRole("button", { name: submitLabel })); + + await waitFor(() => expect( + screen.queryByText(/Пароль: от 8 до 72 символов/) === null, + ).toBe(submitted)); + expect(onSubmit).toHaveBeenCalledTimes(submitted ? 1 : 0); + }); }); diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index cc9bfc5..ba8d0ce 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -746,10 +746,13 @@ export interface components { detail: string; }[]; }; - CredentialsDto: { + RegisterDto: { /** @example user@example.com */ email: string; - /** @example a secure password */ + /** + * @description Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized. + * @example a secure password + */ password: string; }; UserDto: { @@ -760,6 +763,15 @@ export interface components { accessToken: string; user: components["schemas"]["UserDto"]; }; + LoginDto: { + /** @example user@example.com */ + email: string; + /** + * @description Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized. + * @example a secure password + */ + password: string; + }; MoneyDto: { currency: string; value: string; @@ -1708,7 +1720,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CredentialsDto"]; + "application/json": components["schemas"]["RegisterDto"]; }; }; responses: { @@ -1776,7 +1788,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CredentialsDto"]; + "application/json": components["schemas"]["LoginDto"]; }; }; responses: { diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 0f62d31..8ed9d81 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -10,7 +10,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialsDto" + "$ref": "#/components/schemas/RegisterDto" } } } @@ -91,7 +91,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialsDto" + "$ref": "#/components/schemas/LoginDto" } } } @@ -5236,7 +5236,7 @@ "code" ] }, - "CredentialsDto": { + "RegisterDto": { "type": "object", "properties": { "email": { @@ -5245,8 +5245,8 @@ }, "password": { "type": "string", - "minLength": 12, - "maxLength": 72, + "description": "Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.", + "pattern": "^[\\x20-\\x7E]{8,72}$", "example": "a secure password" } }, @@ -5285,6 +5285,24 @@ "user" ] }, + "LoginDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "password": { + "type": "string", + "description": "Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized.", + "example": "a secure password" + } + }, + "required": [ + "email", + "password" + ] + }, "MoneyDto": { "type": "object", "properties": { diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index e780eb3..2f7d165 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -55,8 +55,9 @@ const ru = { requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.", validation: { invalidEmail: "Некорректный email", - invalidPasswordLength: "Пароль должен содержать от 12 до 72 байт", + invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел", passwordsDoNotMatch: "Пароли не совпадают", + passwordTooLong: "Пароль не должен превышать 72 байта", }, }, bank: { -- 2.54.0