fix: align password validation with backend ASCII policy (WealthLedger/backend#60)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m39s

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).
This commit is contained in:
2026-10-16 09:42:07 +03:00
parent 6f1226b668
commit ffc5aaa84a
6 changed files with 169 additions and 24 deletions
@@ -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);
});
});
+33 -9
View File
@@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const MIN_PASSWORD_BYTES = 12; const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72; 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 { function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0); 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 const email = z
.string() .string()
.trim() .trim()
@@ -33,28 +43,42 @@ const email = z
{ error: validationMessage("auth.validation.invalidEmail") }, { 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() .string()
.min(1)
.refine( .refine(
value => value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES { error: validationMessage("auth.validation.passwordTooLong") },
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.invalidPasswordLength") },
); );
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({ const loginSchema = z.object({
email, email,
password, password: loginPassword,
passwordConfirmation: z.string().optional(), passwordConfirmation: z.string().optional(),
}); });
const registerSchema = loginSchema.refine( const registerSchema = loginSchema
.extend({ password: registerPassword })
.refine(
value => value.password === value.passwordConfirmation, value => value.password === value.passwordConfirmation,
{ {
error: validationMessage("auth.validation.passwordsDoNotMatch"), error: validationMessage("auth.validation.passwordsDoNotMatch"),
path: ["passwordConfirmation"], path: ["passwordConfirmation"],
}, },
); );
type AuthFormValues = z.input<typeof loginSchema>; type AuthFormValues = z.input<typeof loginSchema>;
@@ -70,4 +70,39 @@ describe("AuthForm", () => {
resolve(); resolve();
await screen.findByRole("link", { name: linkLabel }); 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(
<MantineProvider>
<MemoryRouter>
<AuthForm onSubmit={onSubmit} register={mode === "register"} />
</MemoryRouter>
</MantineProvider>,
);
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);
});
}); });
+16 -4
View File
@@ -746,10 +746,13 @@ export interface components {
detail: string; detail: string;
}[]; }[];
}; };
CredentialsDto: { RegisterDto: {
/** @example user@example.com */ /** @example user@example.com */
email: string; 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; password: string;
}; };
UserDto: { UserDto: {
@@ -760,6 +763,15 @@ export interface components {
accessToken: string; accessToken: string;
user: components["schemas"]["UserDto"]; 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: { MoneyDto: {
currency: string; currency: string;
value: string; value: string;
@@ -1708,7 +1720,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CredentialsDto"]; "application/json": components["schemas"]["RegisterDto"];
}; };
}; };
responses: { responses: {
@@ -1776,7 +1788,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CredentialsDto"]; "application/json": components["schemas"]["LoginDto"];
}; };
}; };
responses: { responses: {
+23 -5
View File
@@ -10,7 +10,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CredentialsDto" "$ref": "#/components/schemas/RegisterDto"
} }
} }
} }
@@ -91,7 +91,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CredentialsDto" "$ref": "#/components/schemas/LoginDto"
} }
} }
} }
@@ -5236,7 +5236,7 @@
"code" "code"
] ]
}, },
"CredentialsDto": { "RegisterDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "email": {
@@ -5245,8 +5245,8 @@
}, },
"password": { "password": {
"type": "string", "type": "string",
"minLength": 12, "description": "Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.",
"maxLength": 72, "pattern": "^[\\x20-\\x7E]{8,72}$",
"example": "a secure password" "example": "a secure password"
} }
}, },
@@ -5285,6 +5285,24 @@
"user" "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": { "MoneyDto": {
"type": "object", "type": "object",
"properties": { "properties": {
+2 -1
View File
@@ -55,8 +55,9 @@ const ru = {
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.", requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
validation: { validation: {
invalidEmail: "Некорректный email", invalidEmail: "Некорректный email",
invalidPasswordLength: "Пароль должен содержать от 12 до 72 байт", invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",
passwordsDoNotMatch: "Пароли не совпадают", passwordsDoNotMatch: "Пароли не совпадают",
passwordTooLong: "Пароль не должен превышать 72 байта",
}, },
}, },
bank: { bank: {