Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/common/validators/strong-password.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'reflect-metadata';

import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

import { IsStrongPassword } from '@/common/validators/strong-password.validator';

class Sample {
@IsStrongPassword()
password!: string;
}

function build(plain: object): Sample {
return plainToInstance(Sample, plain);
}

describe('IsStrongPassword', () => {
it.each([
['8자 + 4종', 'Aa1!aaaa'],
['긴 비밀번호', 'My!Sup3rL0ngPassword#WithSymbols'],
[
'64자 경계',
'A'.repeat(15) + 'a'.repeat(15) + '0'.repeat(15) + '!'.repeat(19),
], // 64
])('허용: %s', async (_label, value) => {
const dto = build({ password: value });
expect(await validate(dto)).toHaveLength(0);
});

it.each([
['7자', 'Aa1!aaa'],
['65자', 'Aa1!' + 'b'.repeat(61)],
['소문자 누락', 'AAAA1111!!!!'],
['대문자 누락', 'aaaa1111!!!!'],
['숫자 누락', 'AAaa!!@@##'],
['특수문자 누락', 'AAaa1122334'],
])('거절: %s', async (_label, value) => {
const dto = build({ password: value });
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('password');
});

it('trim 후 길이 판정: 앞뒤 공백만으로 길이 채울 수 없음', async () => {
const dto = build({ password: ' Aa1!aa ' }); // 12 chars raw, 7 trimmed
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});

it('문자열이 아닌 값은 거절한다', async () => {
const dto = build({ password: 12345678 });
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});
});
40 changes: 40 additions & 0 deletions src/common/validators/strong-password.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {
ValidationArguments,
ValidationOptions,
ValidatorConstraintInterface,
} from 'class-validator';
import { Validate, ValidatorConstraint } from 'class-validator';

/**
* 강력한 비밀번호 정책 검증.
*
* 왜: 판매자 비밀번호 변경 등에서 길이 8~64 + 소문자/대문자/숫자/특수문자
* 4종 포함을 요구한다. 기존 auth.service.assertStrongPassword 의 로직을
* 그대로 옮겨와 DTO 레이어에서 일원화.
*
* 길이 판정은 trim 후 기준 (기존 동작 호환).
*/
@ValidatorConstraint({ name: 'IsStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string') return false;
const pw = value.trim();
if (pw.length < 8 || pw.length > 64) return false;
return (
/[a-z]/.test(pw) &&
/[A-Z]/.test(pw) &&
/[0-9]/.test(pw) &&
/[^A-Za-z0-9]/.test(pw)
);
}

defaultMessage(args: ValidationArguments): string {
return `${args.property} must be 8~64 characters and include lower/upper case, number, and special character.`;
}
}

export function IsStrongPassword(
validationOptions?: ValidationOptions,
): PropertyDecorator {
return Validate(IsStrongPasswordConstraint, [], validationOptions);
}
103 changes: 4 additions & 99 deletions src/features/auth/auth.seller.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,47 +347,10 @@ describe('AuthService (seller)', () => {
).rejects.toThrow('Only SELLER account is allowed.');
});

it('현재 비밀번호가 빈 문자열이면 BadRequestException을 던져야 한다', async () => {
// Arrange
repo.findSellerCredentialByAccountId.mockResolvedValue(
sellerCredential as never,
);

// Act & Assert
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: '',
newPassword: 'NewPassword!456',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: '',
newPassword: 'NewPassword!456',
req: mockReq,
}),
).rejects.toThrow('Current and new password are required.');
});

it('새 비밀번호가 빈 문자열이면 BadRequestException을 던져야 한다', async () => {
// Arrange
repo.findSellerCredentialByAccountId.mockResolvedValue(
sellerCredential as never,
);

// Act & Assert
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: 'OldPassword!123',
newPassword: '',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);
});
// NOTE: currentPassword/newPassword 의 형식 검증(빈 문자열, 길이, 복잡도)은
// DTO + ValidationPipe 책임으로 이전됨 (P0-3).
// - 길이/필수 검증: seller-change-password.input.spec.ts
// - 강 정책 검증: strong-password.validator.spec.ts

it('현재 비밀번호가 틀리면 UnauthorizedException을 던져야 한다', async () => {
// Arrange
Expand Down Expand Up @@ -415,64 +378,6 @@ describe('AuthService (seller)', () => {
).rejects.toThrow('Current password is invalid.');
});

it('새 비밀번호가 정책(8~64자, 대소문자/숫자/특수문자)을 위반하면 BadRequestException을 던져야 한다', async () => {
// Arrange
repo.findSellerCredentialByAccountId.mockResolvedValue(
sellerCredential as never,
);
jest.spyOn(argon2, 'verify').mockResolvedValue(true);

// 너무 짧은 비밀번호
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: 'OldPassword!123',
newPassword: 'Ab1!',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);

// 소문자 없음
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: 'OldPassword!123',
newPassword: 'ABCDEFGH!123',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);

// 대문자 없음
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: 'OldPassword!123',
newPassword: 'abcdefgh!123',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);

// 숫자 없음
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: 'OldPassword!123',
newPassword: 'Abcdefgh!xyz',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);

// 특수문자 없음
await expect(
service.changeSellerPassword({
accountId: BigInt(10),
currentPassword: 'OldPassword!123',
newPassword: 'Abcdefgh1234',
req: mockReq,
}),
).rejects.toThrow(BadRequestException);
});

it('새 비밀번호가 기존 비밀번호와 동일하면 BadRequestException을 던져야 한다', async () => {
// Arrange
repo.findSellerCredentialByAccountId.mockResolvedValue(
Expand Down
31 changes: 1 addition & 30 deletions src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,7 @@ export class AuthService {
throw new ForbiddenException('Only SELLER account is allowed.');
}

const currentPassword = args.currentPassword;
const newPassword = args.newPassword;
if (!currentPassword || !newPassword) {
throw new BadRequestException('Current and new password are required.');
}
const { currentPassword, newPassword } = args;

const isCurrentPasswordValid = await argon2.verify(
credential.password_hash,
Expand All @@ -353,8 +349,6 @@ export class AuthService {
throw new UnauthorizedException('Current password is invalid.');
}

this.assertStrongPassword(newPassword);

const isSamePassword = await argon2.verify(
credential.password_hash,
newPassword,
Expand Down Expand Up @@ -656,29 +650,6 @@ export class AuthService {
};
}

/**
* 판매자 비밀번호 정책을 검증한다.
*
* @param rawPassword 입력 비밀번호
*/
private assertStrongPassword(rawPassword: string): void {
const password = rawPassword.trim();
if (password.length < 8 || password.length > 64) {
throw new BadRequestException('Password length must be 8~64.');
}

const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecial = /[^A-Za-z0-9]/.test(password);

if (!hasLower || !hasUpper || !hasNumber || !hasSpecial) {
throw new BadRequestException(
'Password must include upper/lower case, number, and special character.',
);
}
}

/**
* access token을 서명한다.
*
Expand Down
26 changes: 6 additions & 20 deletions src/features/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
import type { Request, Response } from 'express';

import { AuthService } from '@/features/auth/auth.service';
import { DevIssueTokenInput } from '@/features/auth/dto/inputs/dev-issue-token.input';
import { SellerChangePasswordInput } from '@/features/auth/dto/inputs/seller-change-password.input';
import { SellerLoginInput } from '@/features/auth/dto/inputs/seller-login.input';
import { parseOidcProvider } from '@/features/auth/types/oidc-provider.type';
import { CurrentUser, JwtAuthGuard, type JwtUser } from '@/global/auth';

Expand Down Expand Up @@ -192,7 +195,7 @@ export class AuthController {
})
@Post('seller/login')
async sellerLogin(
@Body() body: SellerLoginBody,
@Body() body: SellerLoginInput,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
Expand Down Expand Up @@ -298,17 +301,14 @@ export class AuthController {
})
@Post('dev/issue-token')
async devIssueToken(
@Body() body: DevIssueTokenBody,
@Body() body: DevIssueTokenInput,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep dev-token production path returning Forbidden

Switching this parameter to a validated DTO makes malformed requests fail in the global ValidationPipe before devIssueToken() reaches its NODE_ENV==='production' guard, so production can now return 400 instead of the documented/previous 403 for this endpoint. I checked src/main.ts and the app uses a global ValidationPipe, which runs before handler logic; this changes behavior specifically for invalid bodies in production and can break callers/tests that rely on a consistent Forbidden response for all production access attempts.

Useful? React with 👍 / 👎.

@Res() res: Response,
): Promise<void> {
if (process.env.NODE_ENV === 'production') {
throw new ForbiddenException(
'/auth/dev/issue-token은 개발 환경에서만 사용 가능합니다.',
);
}
if (!body || typeof body.accountId !== 'string') {
throw new BadRequestException('accountId(string)가 필요합니다.');
}

const accountId = parseAccountIdString(body.accountId);
const result = await this.auth.issueDevAccessToken(accountId);
Expand Down Expand Up @@ -337,7 +337,7 @@ export class AuthController {
@Post('seller/change-password')
async sellerChangePassword(
@CurrentUser() user: JwtUser,
@Body() body: SellerChangePasswordBody,
@Body() body: SellerChangePasswordInput,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
Expand All @@ -352,20 +352,6 @@ export class AuthController {
}
}

interface SellerLoginBody {
username: string;
password: string;
}

interface SellerChangePasswordBody {
currentPassword: string;
newPassword: string;
}

interface DevIssueTokenBody {
accountId: string;
}

function parseAccountId(user: JwtUser): bigint {
try {
return BigInt(user.accountId);
Expand Down
49 changes: 49 additions & 0 deletions src/features/auth/dto/inputs/dev-issue-token.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'reflect-metadata';

import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

import { DevIssueTokenInput } from '@/features/auth/dto/inputs/dev-issue-token.input';

function build(plain: object): DevIssueTokenInput {
return plainToInstance(DevIssueTokenInput, plain);
}

describe('DevIssueTokenInput', () => {
it.each([
['1', '1'],
['123', '123'],
['0', '0'],
['999999999999999999', '999999999999999999'], // BigInt 범위
])('허용: %s', async (_label, value) => {
const dto = build({ accountId: value });
expect(await validate(dto)).toHaveLength(0);
});

it.each([
['빈 문자열', ''],
['음수', '-1'],
['소수', '1.5'],
['알파벳 혼재', 'abc'],
['공백 포함', ' 1'],
['끝 공백', '1 '],
])('거절: %s ("%s")', async (_label, value) => {
const dto = build({ accountId: value });
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('accountId');
});

it('accountId 누락 거절', async () => {
const dto = build({});
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('accountId');
});

it('숫자 타입은 거절한다', async () => {
const dto = build({ accountId: 123 });
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});
});
12 changes: 12 additions & 0 deletions src/features/auth/dto/inputs/dev-issue-token.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsString, Matches } from 'class-validator';

/**
* POST /auth/dev/issue-token Body (개발 환경 한정).
*
* accountId 는 BigInt 호환 숫자 문자열. 부호/소수 불허.
*/
export class DevIssueTokenInput {
@IsString()
@Matches(/^\d+$/, { message: 'accountId must be a numeric string.' })
accountId!: string;
}
Loading