Home Spring, restful api, security
Post
Cancel

Spring, restful api, security

  • spring security 적용 후 @EnableMethodSecurity를 적용한 컨트롤러 테스트

spring security 구성

  • 기존 작성된 코드에서 security 의존을 분리 시키기 위한 permitAll()처리
  • 이후에 작성되는 코드에 대해서만 security테스트 코드 작성

  • SecurityFilterChain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Slf4j
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

    httpSecurity
      .cors(AbstractHttpConfigurer::disable)
      .csrf(AbstractHttpConfigurer::disable)
      .authorizeHttpRequests((requests) -> requests
        .requestMatchers("**.ico", "/css/**", "/js/**", "/").permitAll()
        .requestMatchers("/api/v1/posts/**").permitAll()
        .anyRequest().authenticated()
      );

    httpSecurity
      .formLogin(withDefaults())
      .httpBasic(withDefaults());

    return httpSecurity.build();

  }
}
  • InMemoryUserDetailService, PasswordEncoder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class ProjectConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  public UserDetailsService userDetailsService() {
    UserDetails admin = User.withUsername("admin").password(passwordEncoder().encode("admin")).roles("ADMIN").build();
    UserDetails user = User.withUsername("user").password(passwordEncoder().encode("user")).roles("USER").build();
    UserDetails guest = User.withUsername("guest").password(passwordEncoder().encode("guest")).roles("GUEST").build();
    return new InMemoryUserDetailsManager(admin, user, guest);
  }

}

테스트 컨트롤러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Slf4j
@RestController
@RequestMapping(path = "/api/v1/security-test")
public class SecurityTestController {

  @PreAuthorize("hasAnyRole('ADMIN','USER','GUEST')")
  @GetMapping(path = "/get-all")
  public String all() {
    return "getAll";
  }

  @GetMapping(path = "/get-admin")
  @PreAuthorize("hasRole('ADMIN')")
  public String getAdmin() {
    return "getAdmin";
  }

  @GetMapping(path = "/get-user")
  @PreAuthorize("hasRole('USER')")
  public String getUser() {
    return "getUser";
  }

  @GetMapping(path = "/get-guest")
  @PreAuthorize("hasRole('GUEST')")
  public String getGuest() {
    return "getGuest";
  }

}


테스트 코드

  • SecuritySetup
    • security가 적용된 통합 테스트를 진행함으로 @SpringBootTest선언.
    • MockMvc 를 이용한 요청 테스트를 진행할 예정임으로 @AutoConfigureMockMvc선언
    • 테스트 코드에서 설정한 security 구성을 가져와야 함으로 mockMvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build() 선언 @BeforeEach메소드 인자에 WebApplicationContext wac를 선언하면 자동으로 WebApplicationContext주입시켜준다.
    • admin, user, guest 테스트 대상 계정 선언 , UserDetailService 해당 서비에서 설정한 계정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@SpringBootTest
@AutoConfigureMockMvc
public abstract class SecuritySetup {

  @Autowired
  protected MockMvc mockMvc;

  @Autowired
  protected UserDetailsService userDetailsService;

  @Autowired
  protected PasswordEncoder passwordEncoder;

  protected UserDetails adminDetails;

  protected UserDetails userDetails;

  protected UserDetails guestDetails;

  @BeforeEach
  void beforeEach(WebApplicationContext wac) {
    mockMvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build();

    adminDetails = userDetailsService.loadUserByUsername("admin");
    Assertions.assertNotNull(adminDetails);

    userDetails = userDetailsService.loadUserByUsername("user");
    Assertions.assertNotNull(userDetails);

    guestDetails = userDetailsService.loadUserByUsername("guest");
    Assertions.assertNotNull(guestDetails);

  }

}


  • SecurityTest
    • 테스트 컨트롤러에 맞게 시나리오 작성후 테스트
      • 인증 정보가 없는 사용자가 접근할 때 401 에러를 뱉는지.
      • 리소스에 접근할 권한이 없는 사용자가 접근할 때 403 에러를 뱉는지.
    • ResultActions requestHelper(String url, UserDetails userDetails)
      • 인증 정보를 인자로 받아 ResultActions을 리턴 해주는 도우미 메소드


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class SecurityTest extends SecuritySetup {

  @Test
  void authentication_test() throws Exception {

    UserDetails mockUser = User.withUsername("mock_user").password("12345").roles("MOCK_USER").build();

    requestHelper("/api/v1/security-test/get-all", mockUser).andExpect(status().isUnauthorized());
    requestHelper("/api/v1/security-test/get-admin", mockUser).andExpect(status().isUnauthorized());
    requestHelper("/api/v1/security-test/get-user", mockUser).andExpect(status().isUnauthorized());
    requestHelper("/api/v1/security-test/get-guest", mockUser).andExpect(status().isUnauthorized());
  }

  @Test
  void authorization_test() throws Exception {

    requestHelper("/api/v1/security-test/get-all", adminDetails).andExpect(status().isOk());
    requestHelper("/api/v1/security-test/get-all", userDetails).andExpect(status().isOk());
    requestHelper("/api/v1/security-test/get-all", guestDetails).andExpect(status().isOk());

    requestHelper("/api/v1/security-test/get-admin", adminDetails).andExpect(status().isOk());
    requestHelper("/api/v1/security-test/get-admin", userDetails).andExpect(status().isForbidden());
    requestHelper("/api/v1/security-test/get-admin", guestDetails).andExpect(status().isForbidden());

    requestHelper("/api/v1/security-test/get-user", adminDetails).andExpect(status().isForbidden());
    requestHelper("/api/v1/security-test/get-user", userDetails).andExpect(status().isOk());
    requestHelper("/api/v1/security-test/get-user", guestDetails).andExpect(status().isForbidden());

    requestHelper("/api/v1/security-test/get-guest", adminDetails).andExpect(status().isForbidden());
    requestHelper("/api/v1/security-test/get-guest", userDetails).andExpect(status().isForbidden());
    requestHelper("/api/v1/security-test/get-guest", guestDetails).andExpect(status().isOk());

  }

  private ResultActions requestHelper(String url, UserDetails userDetails) throws Exception {
    return mockMvc.perform(
      get(url)
        .with(httpBasic(userDetails.getUsername(), userDetails.getUsername()))
    );
  }

}


  • 실행결과

test_fail

  • 발생 원인
    • 이전에 설정 했던 ExceptionHandler에서 해당 에러를 캐치해서 500코드로 뱉어내서 발생
  • 문제 코드
    • 디버깅으로 확인했을떄 해당 부분에서 상태 코드를 500으로 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalControllerAdvice extends ResponseEntityExceptionHandler {
  //  ...
  @ExceptionHandler(Exception.class)
  public ResponseEntity<ErrorDetails<String>> globalExceptionHandler(
    Exception exception, WebRequest webRequest
  ) {
    ErrorDetails<String> errorDetails = new ErrorDetails<>(exception.getMessage(), webRequest.getDescription(false));
    log.info("exception controllerAdviceResponse = {}", errorDetails);
    return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
  }
  //   ...
}

  • 해결 방법
    • Exception에 잡히기 전에 AccessDeniedException를 먼저 처리 하여 반환
      • AccessDeniedException : 리소스에 대한 접근 권한이 없을 때 발생하는 에러
  • 추가된 ExcptionHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalControllerAdvice extends ResponseEntityExceptionHandler {
  //  ...
  @ExceptionHandler(AccessDeniedException.class)
  public ResponseEntity<ErrorDetails<String>> accessDeniedExceptionHandler(
    AccessDeniedException exception, WebRequest webRequest
  ) {
    ErrorDetails<String> errorDetails = new ErrorDetails<>(exception.getMessage(), webRequest.getDescription(false));
    log.info("accessDeniedException controllerAdviceResponse = {}", errorDetails);
    return new ResponseEntity<>(errorDetails, HttpStatus.FORBIDDEN);
  }
  //   ...
}


  • 테스트 재실행
    • 리소스 접근 권한이 없을 때 반환 상태 코드가 403으로 바뀜!

test_pass

This post is licensed under CC BY 4.0 by the author.