fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) #109
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
|
||||||
import {
|
import {
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
|
|||||||
forbidUnknownValues: true, transform: true, whitelist: true });
|
forbidUnknownValues: true, transform: true, whitelist: true });
|
||||||
|
|
||||||
it('requires credentials and normalizes a valid email', async () => {
|
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(
|
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
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 () => {
|
it('requires and validates nested money in a new card', async () => {
|
||||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||||
const card = {
|
const card = {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r
|
|||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { LoginDto, RegisterDto } from './auth.dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token';
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
@@ -56,7 +56,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async register(
|
async register(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: RegisterDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return this.respond(
|
return this.respond(
|
||||||
@@ -69,7 +69,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async login(
|
async login(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: LoginDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return this.respond(
|
return this.respond(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
Matches,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
|
|||||||
const MAX_EMAIL_LENGTH = 254;
|
const MAX_EMAIL_LENGTH = 254;
|
||||||
const ASCII_SPACE = 32;
|
const ASCII_SPACE = 32;
|
||||||
const ASCII_DELETE = 127;
|
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 MAX_PASSWORD_BYTES = 72;
|
||||||
|
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'email' })
|
@ValidatorConstraint({ name: 'email' })
|
||||||
class EmailConstraint {
|
class EmailConstraint {
|
||||||
@@ -40,8 +43,9 @@ class EmailConstraint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
// Login stays lenient: accounts created under an older policy must still sign in.
|
||||||
class PasswordBytesConstraint {
|
@ValidatorConstraint({ name: 'passwordMaxBytes' })
|
||||||
|
class PasswordMaxBytesConstraint {
|
||||||
validate(value: unknown): boolean {
|
validate(value: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
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' })
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }: { value: unknown }) =>
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
@@ -62,14 +66,28 @@ export class CredentialsDto {
|
|||||||
)
|
)
|
||||||
@Validate(EmailConstraint)
|
@Validate(EmailConstraint)
|
||||||
declare email: string;
|
declare email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterDto extends EmailDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
minLength: MIN_PASSWORD_LENGTH,
|
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
|
||||||
maxLength: MAX_PASSWORD_BYTES,
|
pattern: PASSWORD_PATTERN,
|
||||||
example: 'a secure password',
|
example: 'a secure password',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_PASSWORD_LENGTH)
|
@Matches(new RegExp(PASSWORD_PATTERN), {
|
||||||
@Validate(PasswordBytesConstraint)
|
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;
|
declare password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { INestApplication } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
|
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
|
||||||
@@ -265,6 +267,45 @@ describe('Authentication (e2e)', () => {
|
|||||||
expect(await app.get(PrismaService).user.count()).toBe(0);
|
expect(await app.get(PrismaService).user.count()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the ASCII 8..72 password policy on register and keeps login lenient', async () => {
|
||||||
|
const validation = (detail: string) => ({
|
||||||
|
status: 400,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
errors: [{ pointer: '#/password', detail }],
|
||||||
|
});
|
||||||
|
for (const [index, password] of ['a'.repeat(8), ' '.repeat(2) + 'b'.repeat(70)].entries()) {
|
||||||
|
const credentials = { email: `user${index}@example.com`, password };
|
||||||
|
await request(app.getHttpServer()).post('/api/v1/auth/register').send(credentials).expect(200);
|
||||||
|
await request(app.getHttpServer()).post('/api/v1/auth/login').send(credentials).expect(200);
|
||||||
|
}
|
||||||
|
for (const password of ['a'.repeat(7), 'a'.repeat(73), 'пароль-пароль', 'passw😀rd', 'pass\tword1']) {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.send({ email: 'rejected@example.com', password })
|
||||||
|
.expect(400)
|
||||||
|
.expect('Content-Type', /application\/problem\+json/)
|
||||||
|
.expect(({ body }: { body: unknown }) =>
|
||||||
|
expect(body).toEqual(validation('must be 8-72 printable ASCII characters')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(await app.get(PrismaService).user.count()).toBe(2);
|
||||||
|
|
||||||
|
// An account created under the previous policy (Cyrillic, 12+ chars) must still log in.
|
||||||
|
const legacy = { email: 'legacy@example.com', password: 'пароль-пароль' };
|
||||||
|
const prisma = app.get(PrismaService);
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { id: randomUUID(), email: legacy.email, passwordHash: await bcrypt.hash(legacy.password, 4) },
|
||||||
|
});
|
||||||
|
await request(app.getHttpServer()).post('/api/v1/auth/login').send(legacy).expect(200);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ email: legacy.email, password: 'a'.repeat(73) })
|
||||||
|
.expect(400)
|
||||||
|
.expect(({ body }: { body: unknown }) =>
|
||||||
|
expect(body).toEqual(validation('must not exceed 72 UTF-8 bytes')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('guards T-Bank connection and never returns a token', async () => {
|
it('guards T-Bank connection and never returns a token', async () => {
|
||||||
const registration = await request(app.getHttpServer())
|
const registration = await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/register')
|
.post('/api/v1/auth/register')
|
||||||
|
|||||||
Reference in New Issue
Block a user