From c54df4f5e21af1e5762e7eab7bfbe69e3a273ebe Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 24 May 2026 02:47:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(common):=20IsStrongPassword=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20validator=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 auth.service.assertStrongPassword(private) 의 길이 8~64 + 4종 문자 클래스 검증 로직을 DTO 레이어에서 재사용 가능한 데코레이터로 이전. trim 후 길이 판정으로 기존 동작 호환. 후속 PR 에서 SellerChangePasswordInput.newPassword 에 적용 + service 측 assertStrongPassword 메서드 제거 예정. --- .../strong-password.validator.spec.ts | 55 +++++++++++++++++++ .../validators/strong-password.validator.ts | 40 ++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/common/validators/strong-password.validator.spec.ts create mode 100644 src/common/validators/strong-password.validator.ts diff --git a/src/common/validators/strong-password.validator.spec.ts b/src/common/validators/strong-password.validator.spec.ts new file mode 100644 index 0000000..16f0ceb --- /dev/null +++ b/src/common/validators/strong-password.validator.spec.ts @@ -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); + }); +}); diff --git a/src/common/validators/strong-password.validator.ts b/src/common/validators/strong-password.validator.ts new file mode 100644 index 0000000..93a2f0e --- /dev/null +++ b/src/common/validators/strong-password.validator.ts @@ -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); +} From bbd2f26619e71fa78b55d7e288e46e8de2f0d830 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 24 May 2026 02:47:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20REST=20Body=20DTO=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20+=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-2 검증 전략 P0-3 단계 2. - SellerLoginInput / SellerChangePasswordInput / DevIssueTokenInput 3종 class DTO 추가. ValidationPipe 가 정식 검증. - auth.controller.ts: 인라인 interface 3종 제거, devIssueToken 의 body 형식 체크 제거 (DTO 가 처리) - auth.service.changeSellerPassword: 빈 문자열 / 강 비밀번호 정책 수동 검증 제거. assertStrongPassword private 메서드 삭제. 검증은 SellerChangePasswordInput 의 @IsStrongPassword 가 담당. - service 단위 spec 정리: DTO 레이어로 이동한 검증 케이스 제거. 도메인 분기(현재 비밀번호 불일치, 동일 비밀번호 거절 등)만 잔존. FE 영향: - 위 3개 엔드포인트가 빈 입력 / 잘못된 형식에 대해 401/500 → 400 으로 정상화. 정상 입력은 응답 동일. - 자세한 협의 사항은 docs/docs_260524_001050_fe-coord-1-rest-body-validation.md --- src/features/auth/auth.seller.service.spec.ts | 103 +----------------- src/features/auth/auth.service.ts | 31 +----- .../auth/controllers/auth.controller.ts | 26 +---- .../dto/inputs/dev-issue-token.input.spec.ts | 49 +++++++++ .../auth/dto/inputs/dev-issue-token.input.ts | 12 ++ .../seller-change-password.input.spec.ts | 53 +++++++++ .../inputs/seller-change-password.input.ts | 19 ++++ .../dto/inputs/seller-login.input.spec.ts | 59 ++++++++++ .../auth/dto/inputs/seller-login.input.ts | 18 +++ 9 files changed, 221 insertions(+), 149 deletions(-) create mode 100644 src/features/auth/dto/inputs/dev-issue-token.input.spec.ts create mode 100644 src/features/auth/dto/inputs/dev-issue-token.input.ts create mode 100644 src/features/auth/dto/inputs/seller-change-password.input.spec.ts create mode 100644 src/features/auth/dto/inputs/seller-change-password.input.ts create mode 100644 src/features/auth/dto/inputs/seller-login.input.spec.ts create mode 100644 src/features/auth/dto/inputs/seller-login.input.ts diff --git a/src/features/auth/auth.seller.service.spec.ts b/src/features/auth/auth.seller.service.spec.ts index 3e25fd1..db04c16 100644 --- a/src/features/auth/auth.seller.service.spec.ts +++ b/src/features/auth/auth.seller.service.spec.ts @@ -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 @@ -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( diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index 3df8d64..c938b86 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -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, @@ -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, @@ -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을 서명한다. * diff --git a/src/features/auth/controllers/auth.controller.ts b/src/features/auth/controllers/auth.controller.ts index 575e288..98a48a2 100644 --- a/src/features/auth/controllers/auth.controller.ts +++ b/src/features/auth/controllers/auth.controller.ts @@ -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'; @@ -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 { @@ -298,7 +301,7 @@ export class AuthController { }) @Post('dev/issue-token') async devIssueToken( - @Body() body: DevIssueTokenBody, + @Body() body: DevIssueTokenInput, @Res() res: Response, ): Promise { if (process.env.NODE_ENV === 'production') { @@ -306,9 +309,6 @@ export class AuthController { '/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); @@ -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 { @@ -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); diff --git a/src/features/auth/dto/inputs/dev-issue-token.input.spec.ts b/src/features/auth/dto/inputs/dev-issue-token.input.spec.ts new file mode 100644 index 0000000..bd54098 --- /dev/null +++ b/src/features/auth/dto/inputs/dev-issue-token.input.spec.ts @@ -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); + }); +}); diff --git a/src/features/auth/dto/inputs/dev-issue-token.input.ts b/src/features/auth/dto/inputs/dev-issue-token.input.ts new file mode 100644 index 0000000..b726e4d --- /dev/null +++ b/src/features/auth/dto/inputs/dev-issue-token.input.ts @@ -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; +} diff --git a/src/features/auth/dto/inputs/seller-change-password.input.spec.ts b/src/features/auth/dto/inputs/seller-change-password.input.spec.ts new file mode 100644 index 0000000..96c6712 --- /dev/null +++ b/src/features/auth/dto/inputs/seller-change-password.input.spec.ts @@ -0,0 +1,53 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SellerChangePasswordInput } from '@/features/auth/dto/inputs/seller-change-password.input'; + +function build(plain: object): SellerChangePasswordInput { + return plainToInstance(SellerChangePasswordInput, plain); +} + +describe('SellerChangePasswordInput', () => { + it('유효 입력 통과', async () => { + const dto = build({ + currentPassword: 'old!Pass1', + newPassword: 'New!Pass1', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('currentPassword 길이 8 미만 거절', async () => { + const dto = build({ currentPassword: 'short', newPassword: 'New!Pass1' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('currentPassword'); + }); + + it('newPassword 강 정책 미충족 (특수문자 누락) 거절', async () => { + const dto = build({ + currentPassword: 'old!Pass1', + newPassword: 'NoSpecial1', + }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('newPassword'); + }); + + it('newPassword 길이 8 미만 거절', async () => { + const dto = build({ + currentPassword: 'old!Pass1', + newPassword: 'Aa1!', + }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('newPassword'); + }); + + it('두 필드 모두 누락 거절', async () => { + const dto = build({}); + const errors = await validate(dto); + expect(errors).toHaveLength(2); + }); +}); diff --git a/src/features/auth/dto/inputs/seller-change-password.input.ts b/src/features/auth/dto/inputs/seller-change-password.input.ts new file mode 100644 index 0000000..0a38bb4 --- /dev/null +++ b/src/features/auth/dto/inputs/seller-change-password.input.ts @@ -0,0 +1,19 @@ +import { IsString, Length } from 'class-validator'; + +import { IsStrongPassword } from '@/common/validators/strong-password.validator'; + +/** + * POST /auth/seller/change-password Body. + * + * currentPassword: 기존 비밀번호. argon2.verify 가 실제 인증을 담당하므로 + * 형식 검증만 (8~64 길이). 강 정책은 적용하지 않는다. + * newPassword: 강 비밀번호 정책 적용 (길이 + 복잡도 4종). + */ +export class SellerChangePasswordInput { + @IsString() + @Length(8, 64) + currentPassword!: string; + + @IsStrongPassword() + newPassword!: string; +} diff --git a/src/features/auth/dto/inputs/seller-login.input.spec.ts b/src/features/auth/dto/inputs/seller-login.input.spec.ts new file mode 100644 index 0000000..e6805f7 --- /dev/null +++ b/src/features/auth/dto/inputs/seller-login.input.spec.ts @@ -0,0 +1,59 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SellerLoginInput } from '@/features/auth/dto/inputs/seller-login.input'; + +function build(plain: object): SellerLoginInput { + return plainToInstance(SellerLoginInput, plain); +} + +describe('SellerLoginInput', () => { + it('유효 입력 통과', async () => { + const dto = build({ username: 'seller01', password: 'Aa1!aaaa' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('username 길이 4 미만 거절', async () => { + const dto = build({ username: 'abc', password: 'Aa1!aaaa' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('username'); + }); + + it('username 길이 50 초과 거절', async () => { + const dto = build({ username: 'a'.repeat(51), password: 'Aa1!aaaa' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('username'); + }); + + it('password 길이 8 미만 거절', async () => { + const dto = build({ username: 'seller01', password: 'short' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('password'); + }); + + it('password 길이 64 초과 거절', async () => { + const dto = build({ username: 'seller01', password: 'a'.repeat(65) }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('password'); + }); + + it('username · password 동시 위반 시 두 에러 보고', async () => { + const dto = build({ username: 'a', password: 'b' }); + const errors = await validate(dto); + expect(errors).toHaveLength(2); + const props = errors.map((e) => e.property).sort(); + expect(props).toEqual(['password', 'username']); + }); + + it('필드 누락 거절', async () => { + const dto = build({}); + const errors = await validate(dto); + expect(errors).toHaveLength(2); + }); +}); diff --git a/src/features/auth/dto/inputs/seller-login.input.ts b/src/features/auth/dto/inputs/seller-login.input.ts new file mode 100644 index 0000000..b0945c3 --- /dev/null +++ b/src/features/auth/dto/inputs/seller-login.input.ts @@ -0,0 +1,18 @@ +import { IsString, Length } from 'class-validator'; + +/** + * POST /auth/seller/login Body. + * + * username 길이 4~50, password 길이 8~64 까지 기본 형식만 검증한다. + * 실제 인증은 argon2.verify 가 담당. 로그인 시점에는 강 정책(복잡도)을 + * 적용하지 않는다 (사용자 등록 시점의 정책만 신뢰). + */ +export class SellerLoginInput { + @IsString() + @Length(4, 50) + username!: string; + + @IsString() + @Length(8, 64) + password!: string; +}