Files
backend/src/modules/auth/presentation/auth.dto.ts
T
ksv741 ec95d2756d
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 4m59s
fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60)
Register accepts only printable ASCII (letters, digits, symbols, space),
8-72 characters, so characters equal bytes and the bcrypt 72-byte limit
holds without silent truncation. Login only caps the password at 72 bytes
so accounts created under the previous policy can still sign in.

CredentialsDto is split into RegisterDto and LoginDto; OpenAPI publishes
the register pattern and length bounds.
2026-10-16 09:41:58 +03:00

94 lines
2.6 KiB
TypeScript

import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsNotEmpty,
IsString,
Matches,
Validate,
ValidatorConstraint,
} from 'class-validator';
const EMAIL_PART_COUNT = 2;
const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes.
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72;
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
@ValidatorConstraint({ name: 'email' })
class EmailConstraint {
validate(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === EMAIL_PART_COUNT &&
!value
.split('')
.some(
(character) =>
/\s/.test(character) ||
character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
) &&
value.split('@').every(Boolean)
);
}
defaultMessage(): string {
return 'must be a valid email address';
}
}
// Login stays lenient: accounts created under an older policy must still sign in.
@ValidatorConstraint({ name: 'passwordMaxBytes' })
class PasswordMaxBytesConstraint {
validate(value: unknown): boolean {
return (
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
);
}
defaultMessage(): string {
return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
}
}
class EmailDto {
@ApiProperty({ example: 'user@example.com' })
@IsString()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@Validate(EmailConstraint)
declare email: string;
}
export class RegisterDto extends EmailDto {
@ApiProperty({
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
pattern: PASSWORD_PATTERN,
example: 'a secure password',
})
@IsString()
@Matches(new RegExp(PASSWORD_PATTERN), {
message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`,
})
declare password: string;
}
export class LoginDto extends EmailDto {
@ApiProperty({
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
example: 'a secure password',
})
@IsString()
@IsNotEmpty()
@Validate(PasswordMaxBytesConstraint)
declare password: string;
}