본문 바로가기
Project Trouble Shooting/[EATceed] 몸무게 증량 어플

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

by Big Sun 2024. 1. 11.
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