ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] @AuthenticationPrincipal을 커스텀 해보자(Feat : ArgumentResolver)
    Project Trouble Shooting/[EATceed] 몸무게 증량 어플 2024. 1. 11. 00:08
    728x90

    문제 발생

     

    필자는 보통 개발할 때, 시큐리티보다는 다른 기능들을 먼저 개발한 후에 시큐리티를 기능을 입히는 것을 선호한다.

    이번 프로젝트 역시 위와 같이 진행했다.

     

    기존 코드는 @AuthenticationPrincipal을 이용해 Authentication을 구현한 UsernamePasswordAuthenticationToken의 Principal인 MemberDetails에서 memberId를 조회하고 있다.

    @GetMapping("/meal")
    public ApiResponse<ApiResponse.CustomBody<GetMealResponse>> getMeal(@AuthenticationPrincipal MemberDetails memberDetails) {
    		Long memberId = memberDetails.getMemberId();
        getMaintainMealUsecase.excecute(memberId);
    		...
    }
    
    @RequiredArgsConstructor
    public class MemberDetails implements UserDetails {
    
        private final MemberEntity memberEntity;
    		...
    }
    

     

    이 코드는 2가지 문제점이 있는 데, memberId를 MEMBER_TB에서 조회해야하는 것 그리고 단위 테스트를 작성하기 힘들다는 것이다.

     

     

    컨트롤러 단위 테스트를 작성하기 힘들다는 것에대해서 구체적으로 말해보자면,,,,

     

    테스트 시 Mock User를 넣어주는 방법은 @WithMockUser@WithUserDetiails가 있다.

    1. @WithMockUser를 이용하는 방법은 username과 roles를 설정해줄 수 있지만, 필자는 UsernamePasswordAuthenticationToken의 principal에 UserDetails를 구현한 MemberDetails를 넣어줬다. 따라서, 해당 객체를 username에 넣는 방법은 선택하지 않았다.

    2. @WithUserDetiails를 이용할 경우 통합 테스트는 별 문제가 없다. 하지만, @WithUserDetiails를 사용하면, 컨트롤러 단위 테스트일경우 “컨트롤러 단위 테스트”라는 목적 자체가 달성되지 않는다. 다시 말해, Controller 계층의 단위테스트의 경우 Serivce와 Repository를 사용하면 안 되는 데, @WithUserDetiails는 UserDetailService의 loadUserByUsername()을 사용한다.
      따라서, @WithUserDetiails의 경우도 사용할 수 없다.

     

    문제 해결

     

     

    필자가 문제를 해결하는 방법은 다음과 같다.

     

    먼저, UsernamePasswordAuthenticationToken을 커스텀하게 구현하여 memberId를 포함시키는 것이다.

     

    @Getter
    public class CustomUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {
        private final Long memberId;
    
        public CustomUsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities, Long memberId) {
            super(principal, credentials, authorities);
            this.memberId = memberId;
        }
    }
    

     

    다음으로, @AuthenticationPrincipal을 커스텀하여 CustomUsernamePasswordAuthenticationToken의 memberId를 가져오는 애노테이션을 만드는 것이다.

     

    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface AuthenticationMemberId {
    }
    

     

    그리고, HandlerMethodArgumentResolver를 구현하여 AuthenticationMemberIdArgumentResolver를 만들어준다.


    HandlerMethodArgumentResolver란!

    HandlerMethodArgumentResolver 인터페이스의 역할은 컨트롤러에서 파라미터를 바인딩 해주는 역할을 한다.

    동작 시점

    1. Client 가 요청한다(Request).
    2. Filter가 Request를 처리한다.
    3. Dispatcher Servlet에서 Request를 처리한다.
    4. 요청을 분석하여 Request에 대한 Hadler Mapping을 한다.

         -  RequestMappingHandlerAdapter(핸들러 매핑에 맞는 어댑터 결정).
         -  Interceptor 처리
         -  Argument Resolver 처리 (등록한 리졸버에 대응되는 파라미터 바인딩)
         -  Message Converter 처리

    5. Controller Method invoke 

     

    AuthenticationMemberIdArgumentResolver는 @AuthenticationMemberId가 있는 파라미터에 memberId를 주입해준다.

     

    @Component
    public class AuthenticationMemberIdArgumentResolver implements HandlerMethodArgumentResolver {
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return findMethodAnnotation(AuthenticationMemberId.class, parameter) != null;
        }
    
        @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication instanceof CustomUsernamePasswordAuthenticationToken) {
                CustomUsernamePasswordAuthenticationToken customUsernamePasswordAuthenticationToken = (CustomUsernamePasswordAuthenticationToken) authentication;
                return customUsernamePasswordAuthenticationToken.getMemberId();
            }
            return null;
        }
    }
    

     

    ArgumentResolver란??

    요청 데이터(Argument)를 분석 및 가공(Resolve)하여 컨트롤러 메서드가 사용할 수 있는 형태로 만드는 것!

     

     

    요렇게 하면, @AuthenticationMemberId를 사용하여 CustomUsernamePasswordAuthenticationToken의 memberId를 가져올 수 있다.

     

    → 첫 번째 문제 해결!

     

     

    다음으로는 테스트 시에 Mock User를 넣어줘야한다.

     

    UsernamePasswordAuthenticationToken을 커스텀하였기 때문에 이에 맞게 @WithMockUser를 커스텀하여 @WithMockGuestUser를 만들어주면된다.

     

    @Retention(RetentionPolicy.RUNTIME)
    @WithSecurityContext(factory = WithCustomMockUserSecurityContextFactory.class)
    public @interface WithMockGuestUser {
    
        long memberId() default 1L;
    }
    
    public class WithCustomMockUserSecurityContextFactory implements WithSecurityContextFactory<WithMockGuestUser> {
    
        @Override
        public SecurityContext createSecurityContext(WithMockGuestUser annotation) {
            Long memberId = annotation.memberId();
            Authentication authentication = new CustomUsernamePasswordAuthenticationToken(null, null, List.of(new SimpleGrantedAuthority("ROLE_GUEST")), memberId);
            SecurityContext context = SecurityContextHolder.getContext();
            context.setAuthentication(authentication);
            return context;
        }
    }
    

     

    이렇게 하면, 컨트롤러 계층 테스트시 memberId를 Mock User에 넣을 수 있어 service와 repository를 이용하지 않는 컨트롤러 계층 테스트에 목적에 부합하는 코드를 작성할 수 있다. -> 두 번째 문제 해결

     

     

    위 문제를 해결하면서, Spring Security의 아키텍처에대한 이해와 Spring MVC에대한 이해 그리고 단위 테스트에대해서 고민해볼 수 있었습니다.

    728x90
Designed by Tistory.