[Spring Security] @AuthenticationPrincipal을 커스텀 해보자(Feat : ArgumentResolver)

2024. 1. 11. 00:08프로젝트/[EATceed] 몸무게 증량 어플

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