작지만 꾸준한 반복

HandlerInterceptor와 ArgumentResolver를 사용한 인증 개선기 - 1 본문

공부기록/Spring

HandlerInterceptor와 ArgumentResolver를 사용한 인증 개선기 - 1

iamjooon2 2023. 5. 8. 01:14

우아한테크코스 장바구니 미션 수행 중, 요구사항으로 인증 기능 구현이 있었습니다.

 

  • 사용자 정보는 요청 Header의 Authorization 필드를 사용해 인증 처리를 하여 얻습니다.
  • 인증 방식은 Basic 인증을 사용합니다.
  • 예시) 
  • Authorization: Basic ZW1haWxAZW1haWwuY29tOnBhc3N3b3Jk
  •  type: Basic
  • credentials : email:password를 base64로 인코딩한 문자열
  • ex) email@email.com:password -> ZW1haWxAZW1haWwuY29tOnBhc3N3b3Jk

처음 구현한 코드부터 같이 보겠습니다.

 

컨트롤러 부분은 다음과 같습니다.

@RestController
public CartController {

    // 필드 및 생성자

    @GetMapping("/carts")
    public ResponseEntity<List<CartResponse>> getCarts(final HttpServletRequest request) {
        final AuthDto authDto = basicAuthorizationExtractor.extract(request);
        final List<CartResponse> productResponses = cartService.selectCart(authDto);
        return ResponseEntity.ok(productResponses);
    }

    @PostMapping("/carts")
    public ResponseEntity<Void> addCart(final HttpServletRequest request, final CreateCartRequest createcCartRequest) {
        final AuthDto authDto = basicAuthorizationExtractor.extract(request);
        final Long id = cartService.insert(createCartRequest.getProductId(), authDto);
        return ResponseEntity.created(URI.create("/carts" + id)).build();
    }
    
    ..

}

 

공통적으로 HttpServletRequest의 request를 받아서, 인증 정보를 담은 AuthDto를 추출해내는 과정이 있습니다.

 

해당 부분에서 호출하는 서비스 부분도 한 번 보겠습니다

public CartService {

    // 생성자 및 필드
    
    @Transactional(readOnly = true)
    public List<CartResponse> selectCart(final AuthDto authDto) {
        final Member member = memberService.find(authDto);
        final Long memberId = member.getId();
        final Optional<List<Cart>> cartsOptional = cartDao.findAllByMemberId(memberId);
        if (cartsOptional.isEmpty()) {
            return new ArrayList<>();
        }
        return cartsOptional.get().stream()
                .map(cart -> productService.findById(cart.getProductId()))
                .map(CartResponse::from)
                .collect(Collectors.toUnmodifiableList());
    }
    
    @Transactional
    public int insert(final Long productId, final AuthDto authDto) {
        final Member member = memberService.find(authDto);
        return cartDao.insert(productId, member.getId());
    }
 }
 
 
 public MemberService {

    // 생성자 및 필드
    
    @Transactional(readOnly = true)
    public Member find(final AuthDto authDto) {
        final Member member = memberDao.findMember(member)
            .orElseThrow(() -> new AuthenticationException());
        return member;
    }
 
 }

 

코드가 클린하지 못한건 넘어가주십사...

 

공통적으로 MemberService의 find를 호출하여 실제 사용자인지 검증하는 과정을 갖고 있습니다.

현재는 메서드가 그렇게 많지 않지만, 앞으로 인증이 필요한 API가 추가될 때마다 컨트롤러에서의 메서드 추출 로직이 추가될 것이고

서비스에서의 검증 로직도 추가될 것입니다🤨 

 

중복이 매우 불-편한 이 상황...

HandlerInterceptor를 만나 편-안해질 수 있었습니다.

 

HandlerInterceptor?

이전에 DispatcherServlet에 대한 포스트  에서 언급하지 못했던 내용인데

Dispatcher Servlet이 먼저 받은 클라이언트의 요청이 컨트롤러로 전달될 때, HandlerInterceptor를 중간중간 거치게 됩니다.

handlerInterceptor

컨트롤러에 도달하기 전의 preHandle

컨트롤러를 거친 후 모델에 도달하기 전의 postHandle

마지막으로 DispatcherServlet을 통해 클라이언트에게 응답을 주기 전의 afterCompletion

 

이렇게 세가지의 메서드를 가지고 있습니다.

 

저는 이중 preHandle 메서드를 사용하여 사용자의 인증 정보를 검증하는 책임을 부여해보겠습니다.

 

아래는 코드입니다.

@Component
public class AuthHandlerInterceptor implements HandlerInterceptor {

    private final AuthorizationExtractor<AuthDto> basicAuthorizationExtractor = new BasicAuthorizationExtractor();
    private final AuthService AuthService;

    public AuthInterceptor(final AuthService AuthService) {
        this.AuthService = AuthService;
    }

    @Override
    public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
        final AuthDto authDto = basicAuthorizationExtractor.extract(request);

        if (AuthService.isInvalidAuth(authDto)) {
            throw new AuthenticationException("회원 정보가 일치하지 않습니다.");
        }

        return true;
    }
}

 

들어온 요청에서 토큰만을 dto의 형태로 추출한 다음, authService의 isInvalidAuth 메서드를 통해 유효한지 확인합니다.

그 후, 올바르지 않다면 예외를 터트리도록하였습니다.

 

AuthService를 주입받기 위해 @Component 어노테이션을 달아 빈에 등록해주었습니다.

 

AuthService에는 이전 MemberService에 있던 find 메서드를 가지고 왔습니다

인증만을 위한 객체를 따로 만들어주었습니다.

@Service
public class AuthService {

    private final MemberDao memberDao;

    @Autowired
    public AuthService(final MemberDao memberDao) {
        this.memberDao = memberDao;
    }

    @Transactional(readOnly = true)
    public boolean isInvalidAuth(final AuthDto authDto) {
        final Optional<Member> memberOptional = memberDao.findByEmailAndPassword(authDto.getEmail(), authDto.getPassword());
        return memberOptional.isEmpty();
    }
}

다시 인터셉터로 돌아가봅시다.

 

이제, 우리가 만든 이 인터셉터를 스프링에서 사용할 수 있도록, WebConfig를 통해 등록해야합니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final AuthHandlerInterceptor authHandlerInterceptor;

    public WebConfig(final AuthHandlerInterceptor authHandlerInterceptor) {
        this.authHandlerInterceptor = authHandlerInterceptor;
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        registry.addInterceptor(authHandlerInterceptor).addPathPatterns("/carts");
    }
}

 

WebConfig를 통해 스프링 빈으로 등록된 authHandlerInterceptor를 주입받은 후, addInterceptors 메서드를 오버라이딩하여 인터셉터로 등록하였습니다.

 

이후 원하는 addPathPatterns를 통해 인증 인터셉터가 필요한 경로를 등록해주었습니다.

 

하지만 토큰에서 추출해낸 AuthDto의 사용자 정보가 여전히 필요한 상황인데요.

이는 ArgumentResolver와 커스텀 어노테이션을 이용하여 해결할 수 있었습니다!

 

ArugmentResolver 적용기는 다음 글에 적도록 하겠습니다.. 내일 캠퍼스 가야돼요...

 

레퍼런스

https://www.baeldung.com/spring-mvc-handlerinterceptor

https://avaldes.com/spring-mvc-interceptor-using-handlerinterceptoradapter-example/

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-handlermapping-interceptor

https://tecoble.techcourse.co.kr/post/2021-05-24-spring-interceptor/