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.
94 lines
2.6 KiB
TypeScript
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;
|
|
}
|