fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 32s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m21s
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 32s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m21s
This commit was merged in pull request #109.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
||||
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
|
||||
import {
|
||||
CreateBankCardDto,
|
||||
UpdateBankCardDto,
|
||||
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
|
||||
forbidUnknownValues: true, transform: true, whitelist: true });
|
||||
|
||||
it('requires credentials and normalizes a valid email', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
||||
const metadata = { type: 'body' as const, metatype: RegisterDto };
|
||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
@@ -29,6 +29,59 @@ describe('DTO validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
|
||||
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
|
||||
// bytes so accounts created under the older policy can still sign in.
|
||||
describe('password policy', () => {
|
||||
const register = { type: 'body' as const, metatype: RegisterDto };
|
||||
const login = { type: 'body' as const, metatype: LoginDto };
|
||||
const body = (password: string) => ({ email: 'user@example.com', password });
|
||||
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
|
||||
pipe.transform(value, metadata).then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
if (!(error instanceof BadRequestException)) throw error;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
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],
|
||||
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
|
||||
['Cyrillic, 8+ chars', 'пароль-пароль', false],
|
||||
['emoji', 'passw😀rd', false],
|
||||
['tab', 'pass\tword1', false],
|
||||
['newline', 'password1\n', false],
|
||||
['DEL', 'password\x7f1', false],
|
||||
['non-string', 12345678, false],
|
||||
])('register: %s', async (_, password, accepted) => {
|
||||
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
|
||||
});
|
||||
|
||||
it('register keeps raw whitespace untouched', async () => {
|
||||
const password = ' spaced pass ';
|
||||
await expect(pipe.transform(body(password), register)).resolves.toEqual(
|
||||
body(password),
|
||||
);
|
||||
});
|
||||
|
||||
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],
|
||||
['non-string', 12345678, false],
|
||||
])('login: %s', async (_, password, accepted) => {
|
||||
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
|
||||
});
|
||||
});
|
||||
|
||||
it('requires and validates nested money in a new card', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||
const card = {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { AuthService } from '../application/auth.service';
|
||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { LoginDto, RegisterDto } from './auth.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
@@ -56,7 +56,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async register(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Body() credentials: RegisterDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
@@ -69,7 +69,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async login(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Body() credentials: LoginDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
MinLength,
|
||||
Matches,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
} from 'class-validator';
|
||||
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
|
||||
const MAX_EMAIL_LENGTH = 254;
|
||||
const ASCII_SPACE = 32;
|
||||
const ASCII_DELETE = 127;
|
||||
const MIN_PASSWORD_LENGTH = 12;
|
||||
// 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 {
|
||||
@@ -40,8 +43,9 @@ class EmailConstraint {
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||
class PasswordBytesConstraint {
|
||||
// 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' &&
|
||||
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'must not exceed 72 UTF-8 bytes';
|
||||
return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialsDto {
|
||||
class EmailDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsString()
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
@@ -62,14 +66,28 @@ export class CredentialsDto {
|
||||
)
|
||||
@Validate(EmailConstraint)
|
||||
declare email: string;
|
||||
}
|
||||
|
||||
export class RegisterDto extends EmailDto {
|
||||
@ApiProperty({
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
maxLength: MAX_PASSWORD_BYTES,
|
||||
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()
|
||||
@MinLength(MIN_PASSWORD_LENGTH)
|
||||
@Validate(PasswordBytesConstraint)
|
||||
@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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user