모르지 않다는 것은 아는것과 다르다.

Spring Security

스프링시큐리티 테스트

채마스 2022. 3. 24. 21:28

스프링시큐리티 테스트

  • SpringSecurity를 사용하는 프로젝트의 테스트는 SpringSecurity가 없는 프로젝트의 테스트와 조금 다른 부분이 있다.
  • SpringSecurity의 테스트에서는 User가 로그인한 상태를 가정하고 테스트해야 하는 경우가 많다.
  • 인증을 받지 않은 상태로 테스트를 하면 SpringSecurity에서 요청 자체를 막기 때문에 테스트가 제대로 동작조차 하지 못한다.
  • 이런 문제는 프로젝트에 spring-security-test를 사용해서 해결할 수 있다.

 

설정

  • 의존성 추가
    • testImplementation 'org.springframework.security:spring-security-test'
  • Test 실행 전 MockMvc에 springSecurity (static 메소드)를 설정한다.
@BeforeEach
public void setUp(@Autowired WebApplicationContext applicationContext) {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
        .apply(springSecurity())
        .build();
}

 

@WithMockUser

  • Mock(가짜) User를 생성하고 Authentication을 만드는 애노테이션이다.
  • 여기서 User는 org.springframework.security.core.userdetails.User를 말한다.
    • 내부에서 UserDetails를 직접 구현해서 Custom User를 만들어 사용하는 경우에는 WithMockUser를 사용하면 문제가 발생할 수 있다.
    • WithMockUser는 org.springframework.security.core.userdetails.User를 만들어 주지만 우리가 필요한 User는 Custom User이기 때문이다.
  • 코드예시
@Test
@WithMockUser
void getNotice_인증있음() throws Exception {
    mockMvc.perform(get("/notice"))
            .andExpect(status().isOk())
            .andExpect(view().name("notice/index"));
}

@Test
@WithMockUser(roles = {"USER"}, username = "admin", password = "admin")
void postNotice_유저인증있음() throws Exception {
    mockMvc.perform(
            post("/notice").with(csrf())
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .param("title", "제목")
                    .param("content", "내용")
    ).andExpect(status().isForbidden()); // 접근 거부
}

@Test
@WithMockUser(roles = {"ADMIN"}, username = "admin", password = "admin")
void postNotice_어드민인증있음() throws Exception {
    mockMvc.perform(
            post("/notice").with(csrf())
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .param("title", "제목")
                    .param("content", "내용")
    ).andExpect(redirectedUrl("notice")).andExpect(status().is3xxRedirection());
}

@Test
@WithMockUser(roles = {"USER"}, username = "admin", password = "admin")
void deleteNotice_유저인증있음() throws Exception {
    Notice notice = noticeRepository.save(new Notice("제목", "내용"));
    mockMvc.perform(
            delete("/notice?id=" + notice.getId()).with(csrf())
    ).andExpect(status().isForbidden()); // 접근 거부
}

@Test
@WithMockUser(roles = {"ADMIN"}, username = "admin", password = "admin")
void deleteNotice_어드민인증있음() throws Exception {
    Notice notice = noticeRepository.save(new Notice("제목", "내용"));
    mockMvc.perform(
            delete("/notice?id=" + notice.getId()).with(csrf())
    ).andExpect(redirectedUrl("notice")).andExpect(status().is3xxRedirection());
}    

 

@WithUserDetails

  • WithMockUser와 마찬가지로 Mock(가짜) User를 생성하고 Authentication을 만듭니다.
  • WithMockUser와 다른점은 가짜 User를 가져올 때, UserDetailsService의 Bean 이름을 넣어줘서 userDetailsService.loadUserByUsername(String username)을 통해 User를 가져온다는 점이다.
  • 코드예시
@BeforeEach
public void setUp(@Autowired WebApplicationContext applicationContext) {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
            .apply(springSecurity())
            .alwaysDo(print())
            .build();
    user = userRepository.save(new User("user123", "user", "ROLE_USER"));
    admin = userRepository.save(new User("admin123", "admin", "ROLE_ADMIN"));
}
  • @WithUserDetails 설정한 데이터를 테스트 전에 위와 같이 만들어 준다.
@Test
@WithUserDetails(
        value = "user123", // userDetailsService를 통해 가져올 수 있는 유저
        userDetailsServiceBeanName = "userDetailsService", // UserDetailsService 구현체의 Bean
        setupBefore = TestExecutionEvent.TEST_EXECUTION // 테스트 실행 직전에 유저를 가져온다.
)
void getNote_인증있음() throws Exception {
    mockMvc.perform(
                    get("/note")
            ).andExpect(status().isOk())
            .andExpect(view().name("note/index"))
            .andDo(print());
}

@Test
@WithUserDetails(
        value = "admin123",
        userDetailsServiceBeanName = "userDetailsService",
        setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void postNote_어드민인증있음() throws Exception {
    mockMvc.perform(
            post("/note").with(csrf())
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .param("title", "제목")
                    .param("content", "내용")
    ).andExpect(status().isForbidden()); // 접근 거부
}

@Test
@WithUserDetails(
        value = "user123",
        userDetailsServiceBeanName = "userDetailsService",
        setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void postNote_유저인증있음() throws Exception {
    mockMvc.perform(
            post("/note").with(csrf())
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .param("title", "제목")
                    .param("content", "내용")
    ).andExpect(redirectedUrl("note")).andExpect(status().is3xxRedirection());
}

@Test
@WithUserDetails(
        value = "user123",
        userDetailsServiceBeanName = "userDetailsService",
        setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void deleteNote_유저인증있음() throws Exception {
    Note note = noteRepository.save(new Note("제목", "내용", user));
    mockMvc.perform(
            delete("/note?id=" + note.getId()).with(csrf())
    ).andExpect(redirectedUrl("note")).andExpect(status().is3xxRedirection());
}

@Test
@WithUserDetails(
        value = "admin123",
        userDetailsServiceBeanName = "userDetailsService",
        setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void deleteNote_어드민인증있음() throws Exception {
    Note note = noteRepository.save(new Note("제목", "내용", user));
    mockMvc.perform(
            delete("/note?id=" + note.getId()).with(csrf()).with(user(admin))
    ).andExpect(status().isForbidden()); // 접근 거부
}
  • userDetailsServiceBeanName = "userDetailsService"
    • Config 에 등록한 userDetailsService 를 넣어준다.
  • setupBefore = TestExecutionEvent.TEST_EXECUTION
    • 테스트 직전에 유저가 세팅된다.

 

@WithAnonymousUser

  • WithMockUser와 동일하지만 인증된 유저 대신에 익명(Anonymous)유저를 Authentication에서 사용한다.
  • 익명이기 때문에 멤버변수에 유저와 관련된 값이 없다.
  • 코드예시
@Test 
@WithAnonymousUser 
public void index_anonymous() throws Exception { 
    mockMvc.perform(get(INDEX_PAGE)) 
        .andDo(print()) 
        .andExpect(status()
        .isOk()); 
}

 

@WithSecurityContext

  • 다른 방식들은 Authentication을 가짜로 만들었다고 한다면 WithSecurityContext는 아예 SecurityContext를 만든다.
  • WithSecurityContextFactory를 Implement한 Class를 factory 에 넣어주면 된다.
  • 코드 예시
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> { 

    @Override 
    public SecurityContext createSecurityContext(WithMockCustomUser annotation) { 
        final SecurityContext context = SecurityContextHolder.createEmptyContext(); 

        final Authentication auth = new JwtAuthenticationToken(new JwtAuthentication(1L, "tester", new Email("test00@gmail.com")),
                                    createAuthorityList(Role.USER.value()));

        context.setAuthentication(auth); 
        return context; 
    } 

}
  • 위와 같이 WithSecurityContextFactory 를 구현한 클래스를 구현해 준다.
  • Authentication 객체를 구현에 맞게 정의한다.
  • 생성된 Authentication 를 바탕으로 SecurityContext 를 생성한다.
  • 그 다음으로 아래와 같이 커스텀 애노테이션을 만들어 준다.
@Retention(RetentionPolicy.RUNTIME) 
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class) 
public @interface WithMockCustomUser { 
    String name() default "name"; 
    String password() default "password";
}
  • 위에서 설정한 대로 만들어진 SecurityContext를 아래와 같이 사용할 수 있다.
@Test
@WithCustomUser(first = "name123", second = "password123")
void WithCustomUserTest() {
    ...
}

 

with(user( ))

  • 다른 방식은 어노테이션 기반인 반면에 이 방식은 직접 User를 MockMvc에 주입하는 방법이다.
  • WithMockUser와 마찬가지로 유저를 생성해서 Principal에 넣고 Authentication을 생성한다.
  • mockMvc.perform(get("/admin").with(user(user)))
  • org.springframework.security.test.web.servlet.request.user를 사용한다.

 

커스텀 애노테이션 적용

  • 어노테이션 value가 중복되어 사용성이 다소 떨어지는 문제가 발생할 수 있다.
  • 이럴 경우 간단히 메타 어노테이션을 활용하여 추가적인 입력을 줄일 수 있다.
  • 코드예시
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "user", roles="USER")
public @interface WithNormalUser {
}
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles="ADMIN")
public @interface WithAdminUser {
}
  • 위와 같이 커스텀 에노테이션 생성한다.
  • User 권한을 가진 유저인 경우
@Test
@WithNormalUser
public void index_user() throws Exception {
    mockMvc.perform(get(INDEX_PAGE))
        .andDo(print())
        .andExpect(status().isOk());
}
  • Admin 권한을 가진 유저인 겨우
@Test
@WithAdminUser
public void admin_admin() throws Exception {
    mockMvc.perform(get(ADMIN_PAGE))
        .andDo(print())
        .andExpect(status().isOk());
}









REFERENCES

'Spring Security' 카테고리의 다른 글

PasswordEncoder  (0) 2022.03.24
JWT  (0) 2022.02.28
ProxyFactory 를 이용한 동적 Method 인가 처리  (0) 2022.02.28
Method 시큐리티 프로세스 커스텀  (0) 2022.02.28
URL 시큐리티 프로세스 커스텀  (0) 2022.02.28