ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring boot] Controller Payload 유효성 검증
    프로젝트/아카이뷰 2024. 1. 17. 09:54

    @Controller Payload 유효성 검증 왜 궁금했을까❓

    클라이언트로부터 전송되는 API Payload에 대해서 검증이 이뤄지지 않아 비즈니스 로직에서 에러가 발생하며 SQL Injection이나 Command Injection과 같은 공격에 대한 취약점이 존재할 수 있을 것이라 생각했다. 요청 API 처리의 시작부분인 Controller에서 Payload를 검증하여 보안성과 안정성을 향상시키고자 한다.

    @Valid / @Validated

    Spring Boot에서 유효성을 검증하는 어노테이션은 두 가지가 존재한다. 각 어노테이션의 차이점을 알아보고 코드에 적용해보도록 하겠다.

    1. @Valid

    • @Valid는 JSR-303 표준 스펙으로 Bean Validator를 이용해 객체의 제약 조건을 검증하는 어노테이션
    • Spring에서는 LocalValidatorFactoryBean이 제약 조건 검증을 처리
    • @Valid는 주로 Controller에서 Request Body를 검증하는데 사용

    1.1 @Valid의 동작 원리

    1. Spring Boot의 모든 요청은 Servlet을 통해 Controller로 전달
    2. 전달 과정에서 Controller Method의 객체를 만들어주는 ArgumentResolver가 동작하는데 @Valid도 해당 Resolver에 의해 처리
    대표적으로 @RequestBody는 JSON 메세지를 객체로 변환하는 작업을 ArgumentResolver의 구현체인 RequestResponseBodyMethodProccessor가 처리하며 @Valid로 시작하는 모든 경우에 검사를 진행한다. 이러한 이유에서 @Valid는 기본적으로 Controller에서만 작동을 하게 된다.

    만약, 검증에 오류가 있다면 MethodArgumentNotValidException 예외가 발생하며 DefaultHandlerExceptionResolver에 의해 400 Bad Request를 발생시킨다.

    2. @Validated

    • @Validated는 Spring AOP를 기반으로 메소드의 요청을 가로채서 유효성을 검증하는 어노테이션
    • Spring에서는 MethodValidationInterceptor가 제약 조건 검증을 처리
    • @Validated는 주로 @PathVariable, @RequestParam, 컨트롤러 외의 다른 계층에서 유효성을 검증하는데 사용

    1.1 @Validated의 동작 원리

    1. @Validated를 클래스 레벨에 선언하면 유효성 검증을 위한 AOP Advice 또는 인터셉터(MethodValidationInterceptor)가 등록
    2. 클래스에 있는 Method들이 호출될 때 AOP의 Pointcut으로 요청을 가로채서 유효성 검증
    이러한 이유로 @Validated를 사용하면 Controller, Service, Repository 등 계층과 무관하게 Spring Bean이라면 유효성을 검증할 수 있다. 이를 적용하기 위해서는 클래스에 @Validated를 작성하고 메소드에는 Validated 어노테이션을 선언하면 된다.

    만약, 검증에 오류가 있다면 ConstraintViolationException 예외가 발생하며 DefaultHandlerExceptionResolver에 선언되어 있지 않아 500 에러를 발생시킨다.

     

    구현

    AutoConfiguration

    Spring Boot AutoConfigure를 보면 ValidationAutoConfiguration이 등록되어 있는 것을 확인할 수 있다. 해당 설정은 build.gradle에 spring-boot-starter-validation을 추가하면 자동으로 설정한다.

    autoconfigure
    build.gradle

    • @Valid를 처리하는 LocalValidatorFactoryBean과 MethodValidationInterceptor 동작을 위해 MethodValidationPostProcessor를 빈으로 등록하며 @Validated 처리를 위해 설정하는 것을 알 수 있다.

     

    @Valid

    • 회원가입 메소드의 파라미터를 검증하기 위해 @Valid를 설정
    @PostMapping  // 회원가입
    public ResponseEntity<Object> userAdd(@RequestBody @Valid UserDto.AddRequestDto requestDto, HttpServletRequest request) {
        service.userAdd(requestDto, request);
        return SuccessResponse.createSuccess(SuccessCode.JOIN_SUCCESS);
    }
    • 각 항목에 대한 제약 사항 설정
    public class UserDto {
        public static class AddRequestDto {
            @NotBlank
            @Min(6)
            private String id;
            @Min(10)
            private String pw;
            @Email
            private String email;
            @NotBlank
            private String name;
        	
            ...
        }
    }
    • 에러 처리를 위해 GlobalExcpetion에서 @Override 수행
    • 유효성 검사에 실패하게 되면 MethodArgumentNotValidException이 발생하고 오류가 발생한 항목들을 BindingResult에 저장
    • 해당 값들을 이용해 클라이언트에게 에러 메세지 응답
    /**
     * RequestBody javax.validation.Valid 
     */
    @Override
    public ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException e,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        final ResponseCode responseCode = ErrorCode.INVALID_PARAMETER;
        
        return handleExceptionInternal(e, responseCode);
    }
    
    private ResponseEntity<Object> handleExceptionInternal(final BindException e, final ResponseCode responseCode) {
        return ResponseEntity.status(responseCode.getHttpStatus())
                .body(makeErrorResponse(e, responseCode));
    }
    
    private ErrorResponse makeErrorResponse(final BindException e, final ResponseCode responseCode) {
        final Map<String, String> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        fieldError -> Optional.ofNullable(fieldError.getDefaultMessage()).orElse("")
                ));
    
        return ErrorResponse.builder()
                .code(responseCode.name())
                .message(responseCode.getMessage())
                .errors(errors)
                .build();
    }
    • POSTMAN으로 테스트한 결과 다음과 같은 에러 응답을 받아볼 수 있었다.

    @Validated

    • @Validated를 Controller 위에 설정
    @Validated
    public class UserController {
        ...
    }
    • 이메일 인증을 위한 메소드에 @Email 설정
    @GetMapping("/find-email")  // 아이디, 패스워드 찾기용 이메일 인증 요청
    public ResponseEntity<Object> findMailSend(@RequestParam("email") @Email String email) {
        mailService.findSendMail(email);
        return SuccessResponse.createSuccess(SuccessCode.EMAIL_SUCCESS);
    }

     

    • 에러 처리를 위해 GlobalException에 ConstraintViolationException 등록
    • 유효성 검사에 실패하게 되면 ContraintViolationException이 발생하고 오류 항목들을 ConstraintViolation에 저장
    • 해당 값들을 이용해 클라이언트에게 에러 메세지 응답
    /**
     * RequestParam, PathVariable 유효성 검사
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<Object> handleConstraintViolationException(final ConstraintViolationException e) {
        final ResponseCode responseCode = ErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(e, responseCode);
    }
    
    
    private ErrorResponse makeErrorResponse(final ConstraintViolationException e, final ResponseCode responseCode) {
        Map<String, String> errors = e.getConstraintViolations().stream()
                .collect(Collectors.toMap(
                        violation -> StreamSupport.stream(violation.getPropertyPath().spliterator(), false)
                                .reduce((first, second) -> second)
                                .get().toString(),
                        ConstraintViolation::getMessage
                ));
    
        return ErrorResponse.builder()
                .code(responseCode.name())
                .message(responseCode.getMessage())
                .errors(errors)
                .build();
    }

     

    • POSTMAN으로 테스트한 결과 다음과 같은 에러 응답을 받아볼 수 있었다.

     

    Custom Annotation

    파라미터를 검증하는 과정에서 JSR에서 제공하는 어노테이션만으로는 아쉬운 부분이 많이 존재한다. 좀 더 세부적이고 꼼꼼하게 파라미터를 검증하고 중복 코드를 제거하기 위해서 Custom Annotation을 만들어 유효성을 검증해보려고 한다.

    Annotation 생성

    @Target({ElementType.FIELD, ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = UserEmailValidator.class)
    public @interface UserEmail {
        String message() default "올바르지 않은 이메일 형식입니다.";
        Class[] groups() default {};
        Class[] payload() default {};
    }
    • @Target - 어노테이션을 필드(멤버 변수, enum)와 파라미터에 선언 가능
    • @Retention - 어노테이션이 런타임까지 유효함
    • @Constraint - UserEmailValidator를 통해 유효성 검사를 진행
    아래 3가지 필드의 경우 JSR-303 표준 어노테이션이 갖는 공통 속성
    • message - 유효성 검증에 실패할 경우 반환될 메세지
    • groups - 유효성 검증이 진행될 그룹 (@Validated group과 동일)
    • payload - 유효성 검증 시에 전달한 메타 정보(제약 사항과 관련된 정보)
    public class UserEmailValidator implements ConstraintValidator<UserEmail, String> {
    
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            if(value == null) {
                return false;
            }
            return value.matches("^[0-9a-zA-Z]([-_₩.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_₩.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$");
        }
    }

     

    Custom Validator 구현

    Custom Validator를 구현하기 위해서는 JSR에서 제공하는 javax.validation의 ConstraintValidator 인터페이스를 구현해야 한다.
    public interface ConstraintValidator<A extends Annotation, T> {
    
        default void initialize(A constraintAnnotation) {
        }
    
        boolean isValid(T value, ConstraintValidatorContext context);
    }
    • 인터페이스는 2가지 제네릭 타입을 받고 있는데 적용될 어노테이션과 타입을 지칭
    • initialize - Validator를 초기화하기 위한 메소드
    • isValid는 유효성을 검증하기 위한 메소드
    initialize는 default 메소드로 구현되어 있어 초기화할 작업이 없다면 따로 구현할 필요가 없으며 inValid 메소드가 처음 호출될 때 한 번만 호출된다. isValid에는 검증하고 싶은 로직을 구현하면 된다.
    public class UserEmailValidator implements ConstraintValidator<UserEmail, String> {
    
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            if(value == null) {
                return false;
            }
            return value.matches("^[0-9a-zA-Z]([-_₩.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_₩.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$");
        }
    }
    • 테스트를 위해 회원가입 DTO에 Custom Annotation을 설정
    public static class AddRequestDto {
        @UserId
        private String id;
        @UserPassword
        private String pw;
        @UserEmail
        private String email;
        @UserName
        private String name;
        
        ...
    }
    • POSTMAN으로 API를 테스트한 결과 아래와 같이 올바르게 에러가 출력되는 것을 확인할 수 있다.

Designed by Tistory.