본문 바로가기

Project/Trouble Shooting

[Independe] Junit5에서 JWT를 이용한 로그인 처리

Spring Security를 사용하면서 Controller를 테스트할 때 인증 및 인가 방법에 대해 고민했던 내용을 정리한 글이다.

 

우선 난 프로젝트의 로그인을 Spring Security와 JWT를 이용해 구현했다.

@Slf4j
public class JwtAuthorizationMacFilter extends OncePerRequestFilter {

    private final JwtTokenVerifier jwtTokenVerifier;

    public JwtAuthorizationMacFilter(JwtTokenVerifier jwtTokenVerifier) {
        this.jwtTokenVerifier = jwtTokenVerifier;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String requestURI = request.getRequestURI();
            boolean isBlackListed = checkBlackList(requestURI);

            if (isBlackListed) {
                jwtTokenVerifier.verifyToken(request);
                filterChain.doFilter(request, response);
            } else {
                filterChain.doFilter(request, response);
            }

        } catch (CustomException e) {
            makeExceptionMessage(response, e.getErrorCode().getErrorMessage(), e);
        }
    }
}

해당 필터를 통해 요청한 url이 로그인이 필요한 url이면 tokenVerifier를 통해 JWT를 검증한다.

 

다음은 Controller의 코드 중 일부이다.

@GetMapping("/api/members")
public List<MembersDto> members(@AuthenticationPrincipal MemberContext memberContext) {

    List<Member> members = memberService.findAll();

    return members.stream().map(
                    m -> new MembersDto(
                            m.getId(),
                            m.getNickname()
                    ))
            .collect(Collectors.toList());
}

간단한게 DB의 모든 회원정보를 반환하는 코드이다. 그러나 이는 로그인을 완료한 사용자만이 데이터를 조회할 수 있다. 이제 이 코드에 대한 테스트 코드를 작성해야 하는데 여기서부터 문제가 시작한다.

 

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MemberApiControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private MemberService memberService;    
    
    @Test
    void membersTest() throws Exception {
        // given

        // when
        ResultActions perform = mockMvc.perform(get("/api/members")
                .contentType(MediaType.APPLICATION_JSON)
                .with(csrf()));

        // then
        perform.andExpect(status().isOk())
                .andExpect(content().contentType("application/json"));
    }
}

 

MockMvc를 이용해 곧바로 url에 요청을 보내면 401 오류가 발생한다. 이를 해결하기 위해 구글링을 해보았는데 테스트 코드에 권한을 부여하기 위한 어노테이션이 존재했다.

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MemberApiControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private MemberService memberService;
    @Autowired
    private PlatformTransactionManager transactionManager;

    private TransactionStatus transactionStatus;
    private final ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    public void setup() {
        transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());

        memberService.join("testUsername", "testPasswrod1!", "testNickname", null, null);
        memberService.join("modifyMember", "testPasswrod1!", "modifyNickname", null, null);
    }

    @AfterEach
    void afterTest() {
        transactionManager.rollback(transactionStatus);
    }
        
    @Test
    @WithUserDetails(value = "testUsername", setupBefore = TestExecutionEvent.TEST_EXECUTION)
    void membersTest() throws Exception {

        // given

        // when
        ResultActions perform = mockMvc.perform(get("/api/members")
                .contentType(MediaType.APPLICATION_JSON)
                .with(csrf()));

        // then
        perform.andExpect(status().isOk())
                .andExpect(content().contentType("application/json"))
                .andExpect(jsonPath("$.length()").value(2));
    }
}

우선 @BeforeEach에 미리 회원 정보를 2개 입력한다. 그러나 동시에 여러 테스트를 실행할 경우 setup 메서드에 등록한 회원 정보가 롤백되지 않는다. TransactionManager를 이용해 @BeforeEach가 시작할 때 트랜잭션을 시작해서 @AfterEach에 트랜잭션을 종료하여 여러 테스트를 실행해도 각 메서드별 회원등록이 따로 일어나게 구현했다.

 

다음은 @WithUserDetails을 통해 권한을 설정하는데 testUsername 이라는 username을 가진 회원 정보를 SecurityContextHolder에 넣어준다. 

그러나 내 필터는 헤더의 토큰을 검증해야 하기 때문에 SecurityContextHolder에 정보가 들어가도 필터에서 예외가 발생한다. 그래서 난 직접 토큰정보를 header에 넣어 요청하기로 했다.

 

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MemberApiControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private PlatformTransactionManager transactionManager;

    private TransactionStatus transactionStatus;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private String accessToken;

    @BeforeEach
    public void setup() throws Exception {
        transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());

        memberService.join("testUsername", "testPasswrod1!", "testNickname", null, null);
        memberService.join("modifyMember", "testPasswrod1!", "modifyNickname", null, null);

        getAccessToken();
    }
    
    @AfterEach
    void afterTest() {
        transactionManager.rollback(transactionStatus);
    }
    
    @Test
    void membersTest() throws Exception {
        // given

        // when
        ResultActions perform = mockMvc.perform(get("/api/members")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", accessToken)
                .with(csrf()));

        // then
        perform.andExpect(status().isOk())
                .andExpect(content().contentType("application/json"))
                .andExpect(jsonPath("$.length()").value(2))
                .andExpect(jsonPath("$[0].nickname").value("testNickname"))
                .andExpect(jsonPath("$[1].nickname").value("modifyNickname"));
    }
    
    private void getAccessToken() throws Exception {
        String username = "testUsername";
        String password = "testPasswrod1!";

        ResultActions perform = mockMvc.perform(post("/api/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(Map.of(
                        "username", username,
                        "password", password
                )))
                .with(csrf()));

        this.accessToken = perform.andReturn().getResponse().getHeader("Authorization");
    }

setup 메서드 내부에 "/api/login" url에 직접 요청해 반환되는 토큰을 가져와 accessToken 필드에 저장한다. 이후 테스트하고 싶은 메서드에 .header("Authorization", accessToken)을 이용해 직접 헤더에 토큰을 넣어 필터를 통과하도록 구현했다.

테스트는 성공적으로 성공했다. 그러나 인증 및 인가는 MemberApiControllerTest에서만 사용하는 것이 아닌 거의 모든 controller 테스트에 필요하므로 메서드가 아닌 클래스를 만들어 재사용하기로 했다.

@Component
public class LoginMemberInjector {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private MemberService memberService;
    private ObjectMapper objectMapper = new ObjectMapper();
    private String refreshToken;
    private String accessToken;

    public void makeAccessAndRefreshToken() throws Exception {
        memberService.join("testUsername", "testPasswrod1!", "testNickname", null, null);

        String username = "testUsername";
        String password = "testPasswrod1!";

        ResultActions perform = mockMvc.perform(post("/api/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(Map.of(
                        "username", username,
                        "password", password
                )))
                .with(csrf()));

        Cookie refreshTokenCookie = perform.andReturn().getResponse().getCookie("refreshToken");
        refreshToken = refreshTokenCookie.getValue();
        accessToken = perform.andReturn().getResponse().getHeader("Authorization");
    }

    public String getRefreshToken() {
        return refreshToken;
    }

    public String getAccessToken() {
        return accessToken;
    }
}

LoginMemberInjector 클래스를 이용해 /api/login url로 요청해 토큰을 저장하는 클래스를 만들었다.

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MemberApiControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private MemberService memberService;
    @Autowired
    private LoginMemberInjector injector;
    @Autowired
    private PlatformTransactionManager transactionManager;

    private TransactionStatus transactionStatus;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private String accessToken;
    private String refreshToken;

    @BeforeEach
    public void setup() throws Exception {
        transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());

        memberService.join("modifyMember", "testPasswrod1!", "modifyNickname", null, null);

        injector.makeAccessAndRefreshToken();
        accessToken = injector.getAccessToken();
        refreshToken = injector.getRefreshToken();
    }

    @AfterEach
    void afterTest() {
        transactionManager.rollback(transactionStatus);
    }
    
    @Test
    void membersTest() throws Exception {
        // given

        // when
        ResultActions perform = mockMvc.perform(get("/api/members")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", accessToken)
                .with(csrf()));

        // then
        perform.andExpect(status().isOk())
                .andExpect(content().contentType("application/json"))
                .andExpect(jsonPath("$.length()").value(2));
    }
}

LoginMemberInjector를 @Autowired를 이용해 자동주입받은 이후 @BeforeEach에서 injector를 이용해 토큰을 가져와 테스트 메서드에 사용한다. 테스트는 당연하게도 통과했다.

 

처음엔 토큰을 가져오는 과정을 클래스가 아닌 어노테이션을 만들어 AOP 기능을 활용해보고자 했다. 하지만 메서드 전이나 후에 로그인을 요청하는 것이 끝이 아니라 토큰을 가져온 뒤 토큰 정보를 메서드 내부에서 요청할 때 헤더에 토큰을 넣어줘야 했다. 메서드 중간에 파라미터를 추가할 방법이 떠오르지 않아 클래스를 생성해 토큰을 가져오는 방법을 선택했다. 반복되는 코드를 최대한 줄여보기 위해 AOP 또는 다른 방법들을 고민해봐야겠다.